From 5072919b616bc1ffec0b53d531c33baec97d2e5d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 11:24:11 +0200 Subject: [PATCH 1/5] Rebuild the schema and API on a working, multi-user foundation Steps 1-4 of the review plan (docs/plans not checked in; see conversation): Step 1 - make it boot from a clean checkout: - Replace the old schema.sql + single ALTER-TABLE migration (which never created the tables) with one init migration: users, sessions, platform, game, all with UUIDv7-ready uuid ids, soft deletes (deleted_at), and a shared change_seq sequence + trigger for a future sync feature. - sqlc now reads the schema from db/migrations directly. - Migrations are embedded in the binary (db/migrations/embed.go) instead of being copied around and found via path fallbacks. - ensureDatabase/runMigrations failures are now fatal, and main pings the pool after connecting, instead of starting an API with no schema. - Switched both the app and golang-migrate to pgx/v5 (dropped lib/pq). - Fixed the Dockerfile's Go version (1.23 -> 1.25) and build target. - Added a postgres healthcheck to docker-compose so the app waits for a ready database instead of racing it. - go.mod tidied, bin/ added to .gitignore, Justfile's docker-compose calls switched to the v2 `docker compose` plugin. Step 2 - auth and per-user data: - internal/auth: argon2id password hashing, random session tokens stored only as a hash. - POST /api/auth/{register,login,logout}, GET /api/auth/me. Sessions work via either an Authorization: Bearer header or an httpOnly cookie. - Every platform/game query is scoped to the authenticated user. Step 3 - API correctness: - internal/model holds the DTOs, with no server-side dependencies, so clients can import it later without pulling in Huma/Echo. - dbError() maps ErrNoRows -> 404, unique violation -> 409, FK violation -> 409, instead of leaking Postgres error text with a 500. - Soft-delete meant the FK on game.platform_id no longer blocks deleting a referenced platform on its own; added a NOT EXISTS guard plus an application-level check so that case now correctly returns 409 instead of silently succeeding (caught by testing the live server, not by a mock). - Added GET /games/{id} and GET /platforms/{id}. All JSON routes moved under /api, freeing / for a future web client. - ListGames/GetGame join platform_name in. - cmd/openapi writes the generated spec to api/openapi.json. Step 4 - tests and CI: - Rewrote the mocks and handler tests for the new Querier shape; added coverage for 401 (no session), 404 (not found / another user's row), 409 (duplicate email, platform referenced by games), 422 (score out of range), and that queries are actually scoped by the authenticated user's id, not just that the right status code comes back. - .github/workflows/ci.yml: go vet, go test, go mod tidy -diff, sqlc diff. Everything above was exercised against a real local Postgres (migrations up/down, the trigger, constraints) and through the running server via curl (register, login, scoping, every status-code case), not just unit tests. Known regression, intentionally deferred to the next step: cmd/tui and cmd/gui still call the old unauthenticated GET /games, which no longer exists. They need internal/client and a login flow before they work again. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 39 ++ .gitignore | 45 +- Dockerfile | 10 +- Justfile | 10 +- README.md | 60 +++ api/openapi.json | 1 + cmd/api/main.go | 116 ++--- cmd/openapi/main.go | 47 ++ .../20251222211500_add_auth_to_users.down.sql | 3 - .../20251222211500_add_auth_to_users.up.sql | 3 - db/migrations/20261003000000_init.down.sql | 10 + db/migrations/20261003000000_init.up.sql | 74 +++ db/migrations/embed.go | 10 + db/query.sql | 154 ++++-- db/schema.sql | 21 - docker-compose.yaml | 10 +- go.mod | 13 +- go.sum | 64 ++- internal/api/auth_routes.go | 160 ++++++ internal/api/context.go | 38 ++ internal/api/errors.go | 52 ++ internal/api/middleware.go | 88 ++++ internal/api/mock_db_test.go | 93 ++-- internal/api/routes.go | 319 +++++++----- internal/api/routes_test.go | 223 +++++++-- internal/auth/password.go | 69 +++ internal/auth/password_test.go | 51 ++ internal/auth/session.go | 37 ++ internal/db/db.go | 2 +- internal/db/models.go | 39 +- internal/db/querier.go | 38 +- internal/db/query.sql.go | 463 +++++++++++++----- internal/model/model.go | 58 +++ sqlc.yaml | 5 +- 34 files changed, 1913 insertions(+), 512 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 api/openapi.json create mode 100644 cmd/openapi/main.go delete mode 100644 db/migrations/20251222211500_add_auth_to_users.down.sql delete mode 100644 db/migrations/20251222211500_add_auth_to_users.up.sql create mode 100644 db/migrations/20261003000000_init.down.sql create mode 100644 db/migrations/20261003000000_init.up.sql create mode 100644 db/migrations/embed.go delete mode 100644 db/schema.sql create mode 100644 internal/api/auth_routes.go create mode 100644 internal/api/context.go create mode 100644 internal/api/errors.go create mode 100644 internal/api/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/session.go create mode 100644 internal/model/model.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15c8c3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go mod tidy (no diff expected) + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + sqlc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: "1.31.1" + + - name: sqlc diff (generated code must match the schema/queries) + run: sqlc diff diff --git a/.gitignore b/.gitignore index f3e8d20..8629865 100644 --- a/.gitignore +++ b/.gitignore @@ -25,46 +25,5 @@ go.work.sum # env file .env -# ---> Lua -# Compiled Lua sources -luac.out - -# luarocks build files -*.src.rock -*.zip -*.tar.gz - -# Object files -*.o -*.os -*.ko -*.obj -*.elf - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo -*.def -*.exp - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - - +# Justfile build output (bin/api, bin/tui, bin/gui) +bin/ diff --git a/Dockerfile b/Dockerfile index 4e60d8d..78c9e01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build Stage -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app @@ -18,7 +18,7 @@ COPY . . # Build the application # CGO_ENABLED=0 ensures a static binary ARG TARGETOS TARGETARCH -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api/main.go +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api # Runtime Stage FROM alpine:latest @@ -28,12 +28,10 @@ WORKDIR /app # Install ca-certificates for database connections (TLS) RUN apk add --no-cache ca-certificates -# Copy the binary from the builder stage +# Copy the binary from the builder stage. Migrations are embedded in the +# binary (see db/migrations/embed.go), so there's nothing else to copy. COPY --from=builder /app/api-server . -# Copy migrations -COPY --from=builder /app/db/migrations ./db/migrations - # Expose the application port EXPOSE 8080 diff --git a/Justfile b/Justfile index 65ebe1d..60b56ef 100644 --- a/Justfile +++ b/Justfile @@ -16,11 +16,11 @@ run-api: # Start the API and Database using Docker Compose docker-up: - docker-compose up --build -d + docker compose up --build -d # Stop Docker Compose services docker-down: - docker-compose down + docker compose down # Run all tests test: @@ -60,6 +60,10 @@ run-gui: sqlc: sqlc generate +# Write the OpenAPI spec to api/openapi.json +openapi: + go run ./cmd/openapi + # Connect to the running database via psql db-shell: - docker-compose exec db psql -U postgres -d gamedb + docker compose exec db psql -U postgres -d gamedb diff --git a/README.md b/README.md index 0ef55ea..c61868b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # Completed +A tracker for a video game collection: what you own, which platform it's on, +your score for it, and whether (and when) you finished it. + +## Status + +Each account has its own private collection. The API is usable; there's no +GUI yet beyond two read-only clients (see below). + +## Running it + +```sh +just docker-up # starts Postgres and the API on :8080 +just docker-down # stops them +``` + +Or against a Postgres you already have running locally: + +```sh +export DATABASE_URL="postgres://postgres:password@localhost:5432/gamedb?sslmode=disable" +just run-api +``` + +The API creates its database and runs migrations on startup if needed. +Browse `http://localhost:8080/docs` for the generated API docs. + +## Using the API + +```sh +# Register (also logs you in) +curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \ + -d '{"email":"you@example.com","password":"a long password","name":"You"}' +# -> {"token": "...", "user": {...}} + +TOKEN=... # the token from above + +curl -X POST localhost:8080/api/platforms -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"name":"PC"}' + +curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN" +``` + +A browser-based client can skip the `Authorization` header: register/login +also set an httpOnly `session` cookie. + +## Other clients + +- `just run-tui` — a read-only terminal viewer of `/games` (no login yet) +- `just run-gui` — a read-only raylib desktop viewer of `/games` (no login + yet; planned to move to Fyne) + +## Development + +```sh +just test # go test ./... +just sqlc # regenerate internal/db after changing db/query.sql or a migration +``` + +Schema changes go in `db/migrations` as a new `_.up.sql` / +`.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly, +so there's no separate schema file to keep in sync. diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..8be1d82 --- /dev/null +++ b/api/openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","email","name"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} diff --git a/cmd/api/main.go b/cmd/api/main.go index 9265dc1..7209db3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -11,21 +11,26 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - _ "github.com/lib/pq" + "completed/db/migrations" "completed/internal/api" "completed/internal/db" ) +// ensureDatabase creates the target database if it doesn't exist yet, by +// connecting to the server's default "postgres" database first. This is a +// convenience for local/compose use; a real deployment would provision the +// database up front and this would be a no-op every time. func ensureDatabase(dbURL string) error { u, err := url.Parse(dbURL) if err != nil { - return err + return fmt.Errorf("parse DATABASE_URL: %w", err) } dbName := strings.TrimPrefix(u.Path, "/") @@ -33,103 +38,104 @@ func ensureDatabase(dbURL string) error { return fmt.Errorf("database name is empty in URL: %s", dbURL) } - // Connect to 'postgres' database to create the new DB u.Path = "/postgres" - db, err := sql.Open("postgres", u.String()) + adminDB, err := sql.Open("pgx", u.String()) if err != nil { - return err + return fmt.Errorf("open admin connection: %w", err) } - defer db.Close() + defer adminDB.Close() - // Check if DB exists var exists bool - err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists) + err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists) if err != nil { - return err + return fmt.Errorf("check database existence: %w", err) } - if !exists { - fmt.Printf("Database '%s' does not exist. Creating...\n", dbName) - _, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", dbName)) - if err != nil { - return err - } - fmt.Printf("Database '%s' created successfully.\n", dbName) - } else { - fmt.Printf("Database '%s' already exists.\n", dbName) + if exists { + return nil } + + fmt.Printf("Database %q does not exist. Creating...\n", dbName) + // Table/database identifiers can't be parameterized; dbName comes from + // our own DATABASE_URL, not untrusted request input. + if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil { + return fmt.Errorf("create database %q: %w", dbName, err) + } + fmt.Printf("Database %q created.\n", dbName) return nil } -func runMigrations(dbURL string) { - m, err := migrate.New( - "file:///app/db/migrations", // Path inside Docker container - dbURL, - ) +// runMigrations applies pending migrations, embedded into the binary via +// db/migrations/embed.go. A failure here is fatal: the alternative is an API +// that starts and answers requests against a schema that was never created. +func runMigrations(dbURL string) error { + source, err := iofs.New(migrations.FS, ".") if err != nil { - // Fallback for local development if not in /app - if os.Getenv("MIGRATION_PATH") != "" { - m, err = migrate.New( - "file://"+os.Getenv("MIGRATION_PATH"), - dbURL, - ) - } else { - // Try relative path default - m, err = migrate.New( - "file://db/migrations", - dbURL, - ) - } + return fmt.Errorf("load embedded migrations: %w", err) } + // golang-migrate's pgx/v5 driver is registered under the "pgx5" scheme; + // swap it in regardless of whether DATABASE_URL says postgres:// or + // postgresql://. + u, err := url.Parse(dbURL) if err != nil { - fmt.Fprintf(os.Stderr, "Migration initialization failed: %v\n", err) - return + return fmt.Errorf("parse DATABASE_URL: %w", err) } + u.Scheme = "pgx5" + + m, err := migrate.NewWithSourceInstance("iofs", source, u.String()) + if err != nil { + return fmt.Errorf("init migrator: %w", err) + } + defer m.Close() if err := m.Up(); err != nil && err != migrate.ErrNoChange { - fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err) - } else { - fmt.Println("Migrations ran successfully") + return fmt.Errorf("apply migrations: %w", err) } + fmt.Println("Migrations applied.") + return nil } func main() { - // 1. Database connection dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { - dbURL = "postgres://user:password@localhost:5432/dbname?sslmode=disable" + // Matches docker-compose.yaml's db service, so `just run-api` + // against `just docker-up`'s database works without extra setup. + dbURL = "postgres://postgres:password@localhost:5432/gamedb?sslmode=disable" } - // Ensure Database Exists if err := ensureDatabase(dbURL); err != nil { - fmt.Fprintf(os.Stderr, "Failed to ensure database: %v\n", err) + fmt.Fprintf(os.Stderr, "Failed to ensure database exists: %v\n", err) + os.Exit(1) } - // Run Migrations - runMigrations(dbURL) + if err := runMigrations(dbURL); err != nil { + fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err) + os.Exit(1) + } pool, err := pgxpool.New(context.Background(), dbURL) if err != nil { - fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err) os.Exit(1) } defer pool.Close() + if err := pool.Ping(context.Background()); err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + queries := db.New(pool) - // 2. Setup Echo e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) - // 3. Setup Huma - humaApi := humaecho.New(e, huma.DefaultConfig("My API", "1.0.0")) + humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) - // 4. Register Routes api.RegisterRoutes(humaApi, queries) - // 5. Start Server port := os.Getenv("PORT") if port == "" { port = "8080" diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go new file mode 100644 index 0000000..07b4dcd --- /dev/null +++ b/cmd/openapi/main.go @@ -0,0 +1,47 @@ +// Command openapi writes the API's OpenAPI spec to api/openapi.json. +// Clients (the web frontend, generated SDKs) treat that file as the +// contract for what the server exposes; regenerate it after any route +// change with `just openapi`. +// +// It builds the same route tree as cmd/api, but against a stub echo +// server with no real database connection: registering routes only +// inspects Go types via reflection and never calls the database, so a nil +// *db.Queries (which still satisfies db.Querier) is enough. +package main + +import ( + "fmt" + "os" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v4" + + "completed/internal/api" + "completed/internal/db" +) + +func main() { + e := echo.New() + humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) + + var queries db.Querier = (*db.Queries)(nil) + api.RegisterRoutes(humaApi, queries) + + data, err := humaApi.OpenAPI().MarshalJSON() + if err != nil { + fmt.Fprintf(os.Stderr, "marshal OpenAPI spec: %v\n", err) + os.Exit(1) + } + + const outPath = "api/openapi.json" + if err := os.MkdirAll("api", 0o755); err != nil { + fmt.Fprintf(os.Stderr, "create api/ directory: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(outPath, append(data, '\n'), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) + os.Exit(1) + } + fmt.Printf("Wrote %s\n", outPath) +} diff --git a/db/migrations/20251222211500_add_auth_to_users.down.sql b/db/migrations/20251222211500_add_auth_to_users.down.sql deleted file mode 100644 index 3616c65..0000000 --- a/db/migrations/20251222211500_add_auth_to_users.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE users -DROP COLUMN email, -DROP COLUMN password_hash; diff --git a/db/migrations/20251222211500_add_auth_to_users.up.sql b/db/migrations/20251222211500_add_auth_to_users.up.sql deleted file mode 100644 index 0f3a471..0000000 --- a/db/migrations/20251222211500_add_auth_to_users.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE users -ADD COLUMN email TEXT NOT NULL UNIQUE, -ADD COLUMN password_hash TEXT NOT NULL; diff --git a/db/migrations/20261003000000_init.down.sql b/db/migrations/20261003000000_init.down.sql new file mode 100644 index 0000000..b42f354 --- /dev/null +++ b/db/migrations/20261003000000_init.down.sql @@ -0,0 +1,10 @@ +DROP TRIGGER IF EXISTS game_touch_change_seq ON game; +DROP TRIGGER IF EXISTS platform_touch_change_seq ON platform; +DROP FUNCTION IF EXISTS touch_change_seq(); + +DROP TABLE IF EXISTS game; +DROP TABLE IF EXISTS platform; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; + +DROP SEQUENCE IF EXISTS change_seq_seq; diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql new file mode 100644 index 0000000..fa5be1c --- /dev/null +++ b/db/migrations/20261003000000_init.up.sql @@ -0,0 +1,74 @@ +-- Requires pgcrypto for gen_random_uuid(); UUIDs are v7 when the app generates +-- them (see internal/model), but the DB default falls back to v4 for rows +-- inserted outside the app (e.g. manual SQL). +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +-- change_seq is a single global sequence shared by platform and game so a +-- client can ask "what changed since N?" across both tables in one pass. +CREATE SEQUENCE change_seq_seq; + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL UNIQUE, + password_hash text NOT NULL, + name text NOT NULL, + bio text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE sessions ( + token_hash bytea PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); + +CREATE TABLE platform ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq'), + UNIQUE (user_id, name) +); + +CREATE INDEX idx_platform_user_change_seq ON platform(user_id, change_seq); + +CREATE TABLE game ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + platform_id uuid NOT NULL REFERENCES platform(id), + name text NOT NULL, + score int NOT NULL CHECK (score BETWEEN 0 AND 100), + release_date date NOT NULL, + finished date, + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq') +); + +CREATE INDEX idx_game_user_change_seq ON game(user_id, change_seq); + +-- Keep updated_at/change_seq current on every UPDATE so sync can detect +-- changes without clients having to set them manually. +CREATE OR REPLACE FUNCTION touch_change_seq() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + NEW.change_seq := nextval('change_seq_seq'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER platform_touch_change_seq + BEFORE UPDATE ON platform + FOR EACH ROW EXECUTE FUNCTION touch_change_seq(); + +CREATE TRIGGER game_touch_change_seq + BEFORE UPDATE ON game + FOR EACH ROW EXECUTE FUNCTION touch_change_seq(); diff --git a/db/migrations/embed.go b/db/migrations/embed.go new file mode 100644 index 0000000..bf8cb46 --- /dev/null +++ b/db/migrations/embed.go @@ -0,0 +1,10 @@ +// Package migrations embeds the SQL migration files into the binary, so the +// server doesn't depend on a db/migrations directory existing next to it at +// runtime (which previously broke outside of a specific Docker layout; see +// cmd/api/main.go). +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/db/query.sql b/db/query.sql index 39b5fd8..a13bd9f 100644 --- a/db/query.sql +++ b/db/query.sql @@ -1,67 +1,123 @@ --- name: GetUser :one -SELECT * FROM users -WHERE id = $1 LIMIT 1; - --- name: ListUsers :many -SELECT * FROM users -ORDER BY name; - -- name: CreateUser :one INSERT INTO users ( - name, bio + email, password_hash, name, bio ) VALUES ( - $1, $2 + $1, $2, $3, $4 ) -RETURNING *; +RETURNING id, email, name, bio, created_at; --- name: DeleteUser :exec -DELETE FROM users +-- name: GetUserByEmail :one +SELECT id, email, password_hash, name, bio, created_at +FROM users +WHERE email = $1; + +-- name: GetUserByID :one +SELECT id, email, name, bio, created_at +FROM users WHERE id = $1; +-- name: CreateSession :exec +INSERT INTO sessions ( + token_hash, user_id, expires_at +) VALUES ( + $1, $2, $3 +); + +-- name: GetSessionAndUser :one +SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +FROM sessions +JOIN users ON users.id = sessions.user_id +WHERE sessions.token_hash = $1 AND sessions.expires_at > now(); + +-- name: TouchSession :exec +UPDATE sessions +SET last_seen_at = now() +WHERE token_hash = $1; + +-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token_hash = $1; + -- name: CreatePlatform :one INSERT INTO platform ( - name + user_id, name ) VALUES ( - $1 + $1, $2 ) -RETURNING *; +RETURNING id, name; + +-- name: GetPlatform :one +SELECT id, name +FROM platform +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL; -- name: ListPlatforms :many -SELECT * FROM platform +SELECT id, name +FROM platform +WHERE user_id = $1 AND deleted_at IS NULL ORDER BY name; --- name: CreateGame :one -INSERT INTO game ( - name, platform_id, score, release_year, finished -) VALUES ( - $1, $2, $3, $4, $5 -) -RETURNING *; - --- name: GetGame :one -SELECT * FROM game -WHERE id = $1 LIMIT 1; - --- name: ListGames :many -SELECT * FROM game -ORDER BY name; - --- name: UpdateGame :one -UPDATE game -SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6 -WHERE id = $1 -RETURNING *; - --- name: DeleteGame :exec -DELETE FROM game -WHERE id = $1; - -- name: UpdatePlatform :one UPDATE platform -SET name = $2 -WHERE id = $1 -RETURNING *; +SET name = $3 +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL +RETURNING id, name; --- name: DeletePlatform :exec -DELETE FROM platform -WHERE id = $1; +-- name: DeletePlatform :execrows +-- Soft delete, so the FK on game.platform_id is never touched and never +-- blocks this on its own; the NOT EXISTS guard is what stops a platform +-- that still has active games from being deleted. Call GetPlatform first to +-- tell "not found" apart from "blocked by games" when this affects 0 rows. +UPDATE platform +SET deleted_at = now() +WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM game + WHERE game.platform_id = platform.id AND game.deleted_at IS NULL + ); + +-- name: CreateGame :one +WITH inserted AS ( + INSERT INTO game ( + user_id, platform_id, name, score, release_date, finished + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + RETURNING id, platform_id, name, score, release_date, finished +) +SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name, + inserted.score, inserted.release_date, inserted.finished +FROM inserted +JOIN platform ON platform.id = inserted.platform_id; + +-- name: GetGame :one +SELECT game.id, game.name, game.platform_id, platform.name AS platform_name, + game.score, game.release_date, game.finished +FROM game +JOIN platform ON platform.id = game.platform_id +WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL; + +-- name: ListGames :many +SELECT game.id, game.name, game.platform_id, platform.name AS platform_name, + game.score, game.release_date, game.finished +FROM game +JOIN platform ON platform.id = game.platform_id +WHERE game.user_id = $1 AND game.deleted_at IS NULL +ORDER BY game.name; + +-- name: UpdateGame :one +WITH updated AS ( + UPDATE game + SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7 + WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL + RETURNING id, platform_id, name, score, release_date, finished +) +SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name, + updated.score, updated.release_date, updated.finished +FROM updated +JOIN platform ON platform.id = updated.platform_id; + +-- name: DeleteGame :execrows +UPDATE game +SET deleted_at = now() +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL; diff --git a/db/schema.sql b/db/schema.sql deleted file mode 100644 index 9ba015c..0000000 --- a/db/schema.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE users ( - id BIGSERIAL PRIMARY KEY, - name text NOT NULL, - email text NOT NULL UNIQUE, - password_hash text NOT NULL, - bio text -); - -CREATE TABLE platform ( - id BIGSERIAL PRIMARY KEY, - name text NOT NULL -); - -CREATE TABLE game ( - id BIGSERIAL PRIMARY KEY, - name text NOT NULL, - platform_id BIGINT NOT NULL REFERENCES platform(id), - score INT NOT NULL, - release_year DATE NOT NULL, - finished DATE -); diff --git a/docker-compose.yaml b/docker-compose.yaml index 0936c24..7765d71 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: app: build: . @@ -9,7 +7,8 @@ services: - PORT=8080 - DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable depends_on: - - db + db: + condition: service_healthy restart: on-failure db: @@ -23,6 +22,11 @@ services: - "5432:5432" volumes: - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d gamedb"] + interval: 5s + timeout: 5s + retries: 10 volumes: db-data: diff --git a/go.mod b/go.mod index 5993383..069d649 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,13 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/danielgtaylor/huma/v2 v2.34.1 + github.com/gen2brain/raylib-go/raylib v0.55.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/labstack/echo/v4 v4.14.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.46.0 ) require ( @@ -20,13 +25,12 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.7.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gen2brain/raylib-go/raylib v0.55.1 // indirect - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -37,11 +41,10 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 94e8194..e7a55fa 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -14,23 +18,46 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -39,6 +66,10 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -55,19 +86,32 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -79,10 +123,18 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= @@ -98,6 +150,8 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/auth_routes.go b/internal/api/auth_routes.go new file mode 100644 index 0000000..a74175c --- /dev/null +++ b/internal/api/auth_routes.go @@ -0,0 +1,160 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "completed/internal/auth" + "completed/internal/db" + "completed/internal/model" +) + +type authOutput struct { + SetCookie string `header:"Set-Cookie"` + Body model.AuthResponse +} + +// sessionCookie builds the Set-Cookie value for a successful login. Secure +// is left unset because the app is also meant to run over plain HTTP in +// local/docker-compose use; put a TLS-terminating proxy in front for +// anything reachable over the network and set Secure there instead. +func sessionCookie(token string) string { + c := http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: auth.SessionDuration, + } + return c.String() +} + +func clearedSessionCookie() string { + c := http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } + return c.String() +} + +// newSession creates and stores a session for userID, returning the token +// to hand back to the client (only its hash is ever persisted). +func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) { + token, tokenHash, err := auth.NewSessionToken() + if err != nil { + return "", err + } + err = queries.CreateSession(ctx, db.CreateSessionParams{ + TokenHash: tokenHash, + UserID: userID, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true}, + }) + return token, err +} + +func registerAuthRoutes(api huma.API, queries db.Querier) { + huma.Register(api, huma.Operation{ + OperationID: "register", + Method: http.MethodPost, + Path: "/api/auth/register", + Summary: "Register a new account", + }, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) { + hash, err := auth.HashPassword(input.Body.Password) + if err != nil { + return nil, huma.Error500InternalServerError("could not process password") + } + + u, err := queries.CreateUser(ctx, db.CreateUserParams{ + Email: input.Body.Email, + PasswordHash: hash, + Name: input.Body.Name, + Bio: pgtype.Text{}, + }) + if err != nil { + return nil, dbError(err) + } + + token, err := newSession(ctx, queries, u.ID) + if err != nil { + return nil, huma.Error500InternalServerError("could not create session") + } + + return &authOutput{ + SetCookie: sessionCookie(token), + Body: model.AuthResponse{ + Token: token, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + }, + }, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "login", + Method: http.MethodPost, + Path: "/api/auth/login", + Summary: "Log in to an existing account", + }, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) { + u, err := queries.GetUserByEmail(ctx, input.Body.Email) + if err != nil { + // Same message whether the email is unknown or the password is + // wrong, so a client can't use this endpoint to enumerate + // registered emails. + return nil, huma.Error401Unauthorized("invalid email or password") + } + + ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash) + if err != nil || !ok { + return nil, huma.Error401Unauthorized("invalid email or password") + } + + token, err := newSession(ctx, queries, u.ID) + if err != nil { + return nil, huma.Error500InternalServerError("could not create session") + } + + return &authOutput{ + SetCookie: sessionCookie(token), + Body: model.AuthResponse{ + Token: token, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + }, + }, nil + }) + + logoutOp := huma.Operation{ + OperationID: "logout", + Method: http.MethodPost, + Path: "/api/auth/logout", + Summary: "Log out of the current session", + } + addAuth(&logoutOp, api, queries) + huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct { + SetCookie string `header:"Set-Cookie"` + }, error) { + _ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx))) + return &struct { + SetCookie string `header:"Set-Cookie"` + }{SetCookie: clearedSessionCookie()}, nil + }) + + meOp := huma.Operation{ + OperationID: "get-me", + Method: http.MethodGet, + Path: "/api/auth/me", + Summary: "Get the current user", + } + addAuth(&meOp, api, queries) + huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) { + return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil + }) +} diff --git a/internal/api/context.go b/internal/api/context.go new file mode 100644 index 0000000..2540fe7 --- /dev/null +++ b/internal/api/context.go @@ -0,0 +1,38 @@ +package api + +import ( + "context" + + "github.com/google/uuid" + + "completed/internal/model" +) + +// authedUser is what AuthMiddleware stores in the request context after +// validating a session token. Handlers read it with userFromContext. +type authedUser struct { + ID uuid.UUID + Email string + Name string + Bio string +} + +type ctxKey string + +const userCtxKey ctxKey = "completed.user" + +func (u authedUser) toModel() model.User { + return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio} +} + +// userFromContext returns the authenticated user set by AuthMiddleware. It +// panics if called from a route that isn't behind AuthMiddleware, since that +// is a programming error (a handler trusting a context value that was never +// guaranteed to be there), not a condition callers should need to check. +func userFromContext(ctx context.Context) authedUser { + u, ok := ctx.Value(userCtxKey).(authedUser) + if !ok { + panic("api: userFromContext called outside AuthMiddleware") + } + return u +} diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..ff9825c --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,52 @@ +package api + +import ( + "errors" + "log" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Postgres error codes this package maps to specific HTTP statuses. +// See https://www.postgresql.org/docs/current/errcodes-appendix.html +const ( + pgCodeUniqueViolation = "23505" + pgCodeForeignKeyViolation = "23503" +) + +// dbError turns a database error into the huma error a handler should +// return, so Postgres's own error text never reaches a client and callers +// get the right HTTP status instead of a generic 500. +func dbError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return huma.Error404NotFound("not found") + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case pgCodeUniqueViolation: + return huma.Error409Conflict("already exists") + case pgCodeForeignKeyViolation: + // A foreign key failure here is either a reference to a platform + // that doesn't exist (create/update) or a delete blocked by + // games that still reference this platform. + return huma.Error409Conflict("referenced by another record, or refers to one that doesn't exist") + } + } + + log.Printf("unmapped database error: %v", err) + return huma.Error500InternalServerError("internal error") +} + +// notFoundIfZero turns the "0 rows affected" result of a soft-delete +// UPDATE into a 404, since the row either never existed or was already +// deleted. +func notFoundIfZero(rows int64) error { + if rows == 0 { + return huma.Error404NotFound("not found") + } + return nil +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..ba0998c --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,88 @@ +package api + +import ( + "context" + "errors" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5" + + "completed/internal/auth" + "completed/internal/db" +) + +// sessionCookieName is also used by the auth routes when setting/clearing +// the cookie, so it lives here as the one shared constant. +const sessionCookieName = "session" + +// bearerToken extracts the token from "Authorization: Bearer ", or +// the empty string if the header is missing or malformed. +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimPrefix(header, prefix) +} + +// tokenFromRequest looks for a session token in the Authorization header +// first (used by the TUI and Fyne clients), falling back to the session +// cookie (used by the web client). +func tokenFromRequest(ctx huma.Context) string { + if t := bearerToken(ctx.Header("Authorization")); t != "" { + return t + } + if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil { + return c.Value + } + return "" +} + +// newAuthMiddleware returns Huma middleware that requires a valid session, +// set via AuthMiddleware on individual operations. On success it stores the +// authenticated user in the request context for userFromContext to read. +func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + token := tokenFromRequest(ctx) + if token == "" { + huma.WriteErr(api, ctx, 401, "authentication required") + return + } + + row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token)) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + // Fail closed: an unreachable DB fails auth, not through. + huma.WriteErr(api, ctx, 401, "authentication required") + return + } + huma.WriteErr(api, ctx, 401, "session expired or invalid") + return + } + + // Best-effort; a failure to record activity shouldn't fail the request. + _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) + + user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} + newCtx := huma.WithValue(ctx, userCtxKey, user) + newCtx = huma.WithValue(newCtx, tokenCtxKey, token) + next(newCtx) + } +} + +// addAuth attaches the auth middleware to op, so it requires a valid +// session. Call it on the *huma.Operation value before huma.Register. +func addAuth(op *huma.Operation, api huma.API, queries db.Querier) { + op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) +} + +// tokenCtxKey holds the raw bearer/cookie token for the current request, so +// the logout handler can hash and delete the exact session that was used +// without re-parsing the request. +const tokenCtxKey ctxKey = "completed.token" + +func tokenFromContext(ctx context.Context) string { + t, _ := ctx.Value(tokenCtxKey).(string) + return t +} diff --git a/internal/api/mock_db_test.go b/internal/api/mock_db_test.go index 2c31b9f..ec1e30a 100644 --- a/internal/api/mock_db_test.go +++ b/internal/api/mock_db_test.go @@ -3,61 +3,84 @@ package api import ( "context" + "github.com/google/uuid" + "completed/internal/db" ) +// MockQuerier is a hand-written fake implementing db.Querier for handler +// tests. Each test sets only the *Func fields its handler path reaches; +// any other field left nil panics on call, which is deliberate: it makes an +// unexpectedly-reached query visible as a test failure instead of a nil +// pointer deep in sqlc-generated code. type MockQuerier struct { - CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error) - CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error) - CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error) - DeleteGameFunc func(ctx context.Context, id int64) error - DeletePlatformFunc func(ctx context.Context, id int64) error - DeleteUserFunc func(ctx context.Context, id int64) error - GetGameFunc func(ctx context.Context, id int64) (db.Game, error) - GetUserFunc func(ctx context.Context, id int64) (db.User, error) - ListGamesFunc func(ctx context.Context) ([]db.Game, error) - ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error) - ListUsersFunc func(ctx context.Context) ([]db.User, error) - UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) - UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) + CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) + CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) + CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error + CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) + DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error) + DeletePlatformFunc func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) + DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error + GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) + GetPlatformFunc func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) + GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) + GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error) + GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) + ListGamesFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) + ListPlatformsFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) + TouchSessionFunc func(ctx context.Context, tokenHash []byte) error + UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) + UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) } -func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.Game, error) { +func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) { return m.CreateGameFunc(ctx, arg) } -func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.Platform, error) { - return m.CreatePlatformFunc(ctx, name) +func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) { + return m.CreatePlatformFunc(ctx, arg) } -func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) { +func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error { + return m.CreateSessionFunc(ctx, arg) +} +func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { return m.CreateUserFunc(ctx, arg) } -func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error { - return m.DeleteGameFunc(ctx, id) +func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) { + return m.DeleteGameFunc(ctx, arg) } -func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error { - return m.DeletePlatformFunc(ctx, id) +func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { + return m.DeletePlatformFunc(ctx, arg) } -func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error { - return m.DeleteUserFunc(ctx, id) +func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error { + return m.DeleteSessionFunc(ctx, tokenHash) } -func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) { - return m.GetGameFunc(ctx, id) +func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { + return m.GetGameFunc(ctx, arg) } -func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) { - return m.GetUserFunc(ctx, id) +func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + return m.GetPlatformFunc(ctx, arg) } -func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) { - return m.ListGamesFunc(ctx) +func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return m.GetSessionAndUserFunc(ctx, tokenHash) } -func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) { - return m.ListPlatformsFunc(ctx) +func (m *MockQuerier) GetUserByEmail(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return m.GetUserByEmailFunc(ctx, email) } -func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) { - return m.ListUsersFunc(ctx) +func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) { + return m.GetUserByIDFunc(ctx, id) } -func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) { +func (m *MockQuerier) ListGames(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) { + return m.ListGamesFunc(ctx, userID) +} +func (m *MockQuerier) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) { + return m.ListPlatformsFunc(ctx, userID) +} +func (m *MockQuerier) TouchSession(ctx context.Context, tokenHash []byte) error { + return m.TouchSessionFunc(ctx, tokenHash) +} +func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) { return m.UpdateGameFunc(ctx, arg) } -func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) { +func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) { return m.UpdatePlatformFunc(ctx, arg) } diff --git a/internal/api/routes.go b/internal/api/routes.go index f8ceef2..7ff8685 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,9 +6,11 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "completed/internal/db" + "completed/internal/model" ) func RegisterRoutes(api huma.API, queries db.Querier) { @@ -33,83 +35,118 @@ func RegisterRoutes(api huma.API, queries db.Querier) { return resp, nil }) + registerAuthRoutes(api, queries) registerPlatformRoutes(api, queries) registerGameRoutes(api, queries) } // --- Platform Routes --- -// PlatformDTO ensures consistent JSON output -type PlatformDTO struct { - ID int64 `json:"id"` - Name string `json:"name"` -} - -type PlatformInput struct { - Name string `json:"name" doc:"Name of the platform"` -} - func registerPlatformRoutes(api huma.API, queries db.Querier) { - huma.Register(api, huma.Operation{ + listOp := huma.Operation{ OperationID: "list-platforms", Method: http.MethodGet, - Path: "/platforms", + Path: "/api/platforms", Summary: "List Platforms", - }, func(ctx context.Context, input *struct{}) (*struct{ Body []PlatformDTO }, error) { - platforms, err := queries.ListPlatforms(ctx) + } + addAuth(&listOp, api, queries) + huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) { + platforms, err := queries.ListPlatforms(ctx, userFromContext(ctx).ID) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - dtos := make([]PlatformDTO, len(platforms)) + dtos := make([]model.Platform, len(platforms)) for i, p := range platforms { - dtos[i] = PlatformDTO{ID: p.ID, Name: p.Name} + dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name} } - return &struct{ Body []PlatformDTO }{Body: dtos}, nil + return &struct{ Body []model.Platform }{Body: dtos}, nil }) - huma.Register(api, huma.Operation{ + getOp := huma.Operation{ + OperationID: "get-platform", + Method: http.MethodGet, + Path: "/api/platforms/{id}", + Summary: "Get Platform", + } + addAuth(&getOp, api, queries) + huma.Register(api, getOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + }) (*struct{ Body model.Platform }, error) { + p, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userFromContext(ctx).ID}) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Platform }{Body: model.Platform{ID: p.ID.String(), Name: p.Name}}, nil + }) + + createOp := huma.Operation{ OperationID: "create-platform", Method: http.MethodPost, - Path: "/platforms", + Path: "/api/platforms", Summary: "Create Platform", - }, func(ctx context.Context, input *struct{ Body PlatformInput }) (*struct{ Body PlatformDTO }, error) { - platform, err := queries.CreatePlatform(ctx, input.Body.Name) - if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) - } - return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil - }) - - huma.Register(api, huma.Operation{ - OperationID: "update-platform", - Method: http.MethodPut, - Path: "/platforms/{id}", - Summary: "Update Platform", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` - Body PlatformInput - }) (*struct{ Body PlatformDTO }, error) { - platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{ - ID: input.ID, - Name: input.Body.Name, + } + addAuth(&createOp, api, queries) + huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { + platform, err := queries.CreatePlatform(ctx, db.CreatePlatformParams{ + UserID: userFromContext(ctx).ID, + Name: input.Body.Name, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil + return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil }) - huma.Register(api, huma.Operation{ + updateOp := huma.Operation{ + OperationID: "update-platform", + Method: http.MethodPut, + Path: "/api/platforms/{id}", + Summary: "Update Platform", + } + addAuth(&updateOp, api, queries) + huma.Register(api, updateOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + Body model.PlatformInput + }) (*struct{ Body model.Platform }, error) { + platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{ + ID: input.ID, + UserID: userFromContext(ctx).ID, + Name: input.Body.Name, + }) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil + }) + + deleteOp := huma.Operation{ OperationID: "delete-platform", Method: http.MethodDelete, - Path: "/platforms/{id}", + Path: "/api/platforms/{id}", Summary: "Delete Platform", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` + } + addAuth(&deleteOp, api, queries) + huma.Register(api, deleteOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` }) (*struct{}, error) { - err := queries.DeletePlatform(ctx, input.ID) + userID := userFromContext(ctx).ID + // Checked separately so a 0-rows result from DeletePlatform below can + // only mean "blocked by games referencing it", not "doesn't exist" - + // soft delete means the FK alone can't tell the two apart (see + // DeletePlatform's comment in db/query.sql). Accepts a race where + // the platform is deleted between these two queries; harmless, since + // the second query would then just affect 0 rows and report 409 + // instead of the 404 a plain retry would hit anyway. + if _, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userID}); err != nil { + return nil, dbError(err) + } + + rows, err := queries.DeletePlatform(ctx, db.DeletePlatformParams{ID: input.ID, UserID: userID}) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) + } + if rows == 0 { + return nil, huma.Error409Conflict("platform still has games; delete or reassign them first") } return nil, nil }) @@ -117,40 +154,21 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { // --- Game Routes --- -// GameDTO for API communication (Clean JSON) -type GameDTO struct { - ID int64 `json:"id"` - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` - Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` -} - -type GameInput struct { - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` - Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` -} - -// Helper to convert DB Game to DTO -func toGameDTO(g db.Game) GameDTO { - dto := GameDTO{ - ID: g.ID, - Name: g.Name, - PlatformID: g.PlatformID, - Score: g.Score, - ReleaseYear: g.ReleaseYear.Time.Format("2006-01-02"), +// formatDate and parseDate convert between the API's YYYY-MM-DD strings and +// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a +// distinct sqlc row type with identical fields (id/name/platform_id/ +// platform_name/score/release_date/finished), so the DTO conversion itself +// is written out at each call site rather than factored into one function. +func formatDate(d pgtype.Date) string { + if !d.Valid { + return "" } - if g.Finished.Valid { - dto.Finished = g.Finished.Time.Format("2006-01-02") - } - return dto + return d.Time.Format("2006-01-02") } -// Helper to parse date string to pgtype.Date +// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string +// parses to an invalid/NULL date, which is valid for the optional +// "finished" field but rejected separately for the required release_date. func parseDate(s string) (pgtype.Date, error) { if s == "" { return pgtype.Date{}, nil @@ -163,95 +181,162 @@ func parseDate(s string) (pgtype.Date, error) { } func registerGameRoutes(api huma.API, queries db.Querier) { - huma.Register(api, huma.Operation{ + listOp := huma.Operation{ OperationID: "list-games", Method: http.MethodGet, - Path: "/games", + Path: "/api/games", Summary: "List Games", - }, func(ctx context.Context, input *struct{}) (*struct{ Body []GameDTO }, error) { - games, err := queries.ListGames(ctx) + } + addAuth(&listOp, api, queries) + huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Game }, error) { + games, err := queries.ListGames(ctx, userFromContext(ctx).ID) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - dtos := make([]GameDTO, len(games)) + dtos := make([]model.Game, len(games)) for i, g := range games { - dtos[i] = toGameDTO(g) + dtos[i] = model.Game{ + ID: g.ID.String(), + Name: g.Name, + PlatformID: g.PlatformID.String(), + PlatformName: g.PlatformName, + Score: g.Score, + ReleaseDate: formatDate(g.ReleaseDate), + Finished: formatDate(g.Finished), + } } - return &struct{ Body []GameDTO }{Body: dtos}, nil + return &struct{ Body []model.Game }{Body: dtos}, nil }) - huma.Register(api, huma.Operation{ + getOp := huma.Operation{ + OperationID: "get-game", + Method: http.MethodGet, + Path: "/api/games/{id}", + Summary: "Get Game", + } + addAuth(&getOp, api, queries) + huma.Register(api, getOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + }) (*struct{ Body model.Game }, error) { + g, err := queries.GetGame(ctx, db.GetGameParams{ID: input.ID, UserID: userFromContext(ctx).ID}) + if err != nil { + return nil, dbError(err) + } + return &struct{ Body model.Game }{Body: model.Game{ + ID: g.ID.String(), + Name: g.Name, + PlatformID: g.PlatformID.String(), + PlatformName: g.PlatformName, + Score: g.Score, + ReleaseDate: formatDate(g.ReleaseDate), + Finished: formatDate(g.Finished), + }}, nil + }) + + createOp := huma.Operation{ OperationID: "create-game", Method: http.MethodPost, - Path: "/games", + Path: "/api/games", Summary: "Create Game", - }, func(ctx context.Context, input *struct{ Body GameInput }) (*struct{ Body GameDTO }, error) { - ry, err := parseDate(input.Body.ReleaseYear) + } + addAuth(&createOp, api, queries) + huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.GameInput }) (*struct{ Body model.Game }, error) { + platformID, err := uuid.Parse(input.Body.PlatformID) if err != nil { - return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid platform_id") + } + ry, err := parseDate(input.Body.ReleaseDate) + if err != nil || !ry.Valid { + return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)") } fin, err := parseDate(input.Body.Finished) if err != nil { - return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)") } game, err := queries.CreateGame(ctx, db.CreateGameParams{ + UserID: userFromContext(ctx).ID, + PlatformID: platformID, Name: input.Body.Name, - PlatformID: input.Body.PlatformID, Score: input.Body.Score, - ReleaseYear: ry, + ReleaseDate: ry, Finished: fin, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + return &struct{ Body model.Game }{Body: model.Game{ + ID: game.ID.String(), + Name: game.Name, + PlatformID: game.PlatformID.String(), + PlatformName: game.PlatformName, + Score: game.Score, + ReleaseDate: formatDate(game.ReleaseDate), + Finished: formatDate(game.Finished), + }}, nil }) - huma.Register(api, huma.Operation{ + updateOp := huma.Operation{ OperationID: "update-game", Method: http.MethodPut, - Path: "/games/{id}", + Path: "/api/games/{id}", Summary: "Update Game", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` - Body GameInput - }) (*struct{ Body GameDTO }, error) { - ry, err := parseDate(input.Body.ReleaseYear) + } + addAuth(&updateOp, api, queries) + huma.Register(api, updateOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` + Body model.GameInput + }) (*struct{ Body model.Game }, error) { + platformID, err := uuid.Parse(input.Body.PlatformID) if err != nil { - return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid platform_id") + } + ry, err := parseDate(input.Body.ReleaseDate) + if err != nil || !ry.Valid { + return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)") } fin, err := parseDate(input.Body.Finished) if err != nil { - return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)") } game, err := queries.UpdateGame(ctx, db.UpdateGameParams{ ID: input.ID, + UserID: userFromContext(ctx).ID, + PlatformID: platformID, Name: input.Body.Name, - PlatformID: input.Body.PlatformID, Score: input.Body.Score, - ReleaseYear: ry, + ReleaseDate: ry, Finished: fin, }) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + return &struct{ Body model.Game }{Body: model.Game{ + ID: game.ID.String(), + Name: game.Name, + PlatformID: game.PlatformID.String(), + PlatformName: game.PlatformName, + Score: game.Score, + ReleaseDate: formatDate(game.ReleaseDate), + Finished: formatDate(game.Finished), + }}, nil }) - huma.Register(api, huma.Operation{ + deleteOp := huma.Operation{ OperationID: "delete-game", Method: http.MethodDelete, - Path: "/games/{id}", + Path: "/api/games/{id}", Summary: "Delete Game", - }, func(ctx context.Context, input *struct { - ID int64 `path:"id"` + } + addAuth(&deleteOp, api, queries) + huma.Register(api, deleteOp, func(ctx context.Context, input *struct { + ID uuid.UUID `path:"id"` }) (*struct{}, error) { - err := queries.DeleteGame(ctx, input.ID) + rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID}) if err != nil { - return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + return nil, dbError(err) } - return nil, nil + return nil, notFoundIfZero(rows) }) } diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 99bdbf6..1ddd215 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -9,58 +9,223 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "completed/internal/auth" "completed/internal/db" ) -func TestListGames(t *testing.T) { - // 1. Setup Mock - mockDB := &MockQuerier{ - ListGamesFunc: func(ctx context.Context) ([]db.Game, error) { - return []db.Game{ - {ID: 1, Name: "Test Game", Score: 90}, - }, nil - }, - } - - // 2. Setup API +// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as +// cmd/api/main.go does, so tests exercise routing and middleware, not just +// the handler bodies. +func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) { e := echo.New() api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) RegisterRoutes(api, mockDB) + return e, api +} - // 3. Execute Request - req := httptest.NewRequest(http.MethodGet, "/games", nil) +// a fixed valid session, returned by GetSessionAndUserFunc so authed +// requests don't need a real database. +var testUserID = uuid.New() + +func authedMock() *MockQuerier { + return &MockQuerier{ + GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil + }, + TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil }, + } +} + +func authedRequest(method, path, body string) *http.Request { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts") + return req +} + +func TestListGamesRequiresAuth(t *testing.T) { + e, _ := newTestAPI(&MockQuerier{}) + + req := httptest.NewRequest(http.MethodGet, "/api/games", nil) rec := httptest.NewRecorder() e.ServeHTTP(rec, req) - // 4. Assert + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestListGames(t *testing.T) { + mockDB := authedMock() + mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) { + assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user") + return []db.ListGamesRow{ + {ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"}, + }, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", "")) + assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Test Game") + assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`) +} + +func TestGetGameNotFoundForOtherUsersGame(t *testing.T) { + // A game that exists, but belongs to someone else: the query is scoped + // by user_id, so sqlc/postgres report it as not found, same as a + // genuinely missing id. The handler must turn that into 404, not 500. + mockDB := authedMock() + mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { + assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user") + return db.GetGameRow{}, pgx.ErrNoRows + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) { + mockDB := authedMock() + mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) { + return 0, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { + // Soft delete means the FK is never actually checked by Postgres (see + // db/query.sql's DeletePlatform comment), so the handler has to notice + // the 0-rows result itself and turn it into 409, not just pass through + // whatever error (if any) the query returns. + mockDB := authedMock() + mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + return db.GetPlatformRow{ID: arg.ID, Name: "PC"}, nil + } + mockDB.DeletePlatformFunc = func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { + return 0, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusConflict, rec.Code) +} + +func TestDeletePlatformNotFound(t *testing.T) { + mockDB := authedMock() + mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + return db.GetPlatformRow{}, pgx.ErrNoRows + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { + mockDB := authedMock() + // No CreateGameFunc set: validation must reject the request before the + // handler ever calls the database, or this test would panic. + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body)) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) } func TestCreatePlatform(t *testing.T) { - // 1. Setup Mock + mockDB := authedMock() + mockDB.CreatePlatformFunc = func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) { + assert.Equal(t, testUserID, arg.UserID) + return db.CreatePlatformRow{ID: uuid.New(), Name: arg.Name}, nil + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Sega Genesis") +} + +func TestRegisterDuplicateEmailConflicts(t *testing.T) { mockDB := &MockQuerier{ - CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) { - return db.Platform{ID: 1, Name: name}, nil + CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { + return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation} }, } - // 2. Setup API - e := echo.New() - api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) - RegisterRoutes(api, mockDB) - - // 3. Execute Request - req := httptest.NewRequest(http.MethodPost, "/platforms", strings.NewReader(`{"name": "Sega Genesis"}`)) - req.Header.Set("Content-Type", "application/json") + e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + body := `{"email":"a@example.com","password":"password123","name":"Alice"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body)) - // 4. Assert + assert.Equal(t, http.StatusConflict, rec.Code) +} + +func TestLoginWrongPasswordUnauthorized(t *testing.T) { + hash, err := auth.HashPassword("correct-password") + require.NoError(t, err) + + mockDB := &MockQuerier{ + GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil + }, + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"email":"a@example.com","password":"wrong-password"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestLoginSuccessSetsSessionCookie(t *testing.T) { + hash, err := auth.HashPassword("correct-password") + require.NoError(t, err) + + mockDB := &MockQuerier{ + GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { + return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil + }, + CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error { + assert.Equal(t, testUserID, arg.UserID) + return nil + }, + } + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + body := `{"email":"a@example.com","password":"correct-password"}` + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) + assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "Sega Genesis") - assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=") } diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..3b5a627 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,69 @@ +// Package auth handles password hashing and session tokens. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters, taken from the RFC 9106 "low memory" recommendation +// (2nd choice) as a reasonable default for a server handling interactive +// logins without dedicated hardware. +const ( + argonTime = 2 + argonMemory = 19 * 1024 // KiB + argonThreads = 1 + argonKeyLen = 32 + saltLen = 16 +) + +// HashPassword returns a self-describing hash string: algorithm, parameters, +// salt and key are all encoded together so the parameters can change later +// without invalidating hashes already stored. +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Key := base64.RawStdEncoding.EncodeToString(key) + return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, b64Salt, b64Key), nil +} + +// VerifyPassword checks password against an encoded hash produced by +// HashPassword. It returns an error only on malformed input; a wrong +// password is reported via the bool return. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "argon2id" { + return false, errors.New("unrecognized hash format") + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, fmt.Errorf("parse hash params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false, fmt.Errorf("decode salt: %w", err) + } + wantKey, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("decode key: %w", err) + } + + gotKey := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantKey))) + return subtle.ConstantTimeCompare(gotKey, wantKey) == 1, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..e48bd8c --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,51 @@ +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if !ok { + t.Error("VerifyPassword returned false for the correct password") + } + + ok, err = VerifyPassword("wrong password", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if ok { + t.Error("VerifyPassword returned true for an incorrect password") + } +} + +func TestVerifyPasswordMalformedHash(t *testing.T) { + if _, err := VerifyPassword("anything", "not-a-valid-hash"); err == nil { + t.Error("expected an error for a malformed hash, got nil") + } +} + +func TestNewSessionTokenIsUnpredictableAndHashVerifiable(t *testing.T) { + token1, hash1, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + token2, _, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if token1 == token2 { + t.Error("two calls to NewSessionToken produced the same token") + } + + gotHash := HashSessionToken(token1) + if string(gotHash) != string(hash1) { + t.Error("HashSessionToken(token) does not match the hash returned alongside it") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..0d23181 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,37 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// SessionDuration controls how long a login stays valid without activity. +// TouchSession bumps last_seen_at on each authenticated request, but +// expires_at (set at login) is what actually gates access. +const SessionDuration = 30 * 24 * 60 * 60 // 30 days, in seconds + +// tokenLen is deliberately large: this is the only secret standing between +// an attacker and a full account take-over, so it gets more entropy than a +// password would need. +const tokenLen = 32 + +// NewSessionToken returns a random token to hand to the client, and the hash +// of it to store in the database. Only the hash is ever persisted, so a +// leaked database dump doesn't also leak usable session tokens. +func NewSessionToken() (token string, hash []byte, err error) { + raw := make([]byte, tokenLen) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("generate token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, HashSessionToken(token), nil +} + +// HashSessionToken hashes a token presented by a client so it can be looked +// up against the stored hash. +func HashSessionToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} diff --git a/internal/db/db.go b/internal/db/db.go index 9d485b5..468d1fa 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db diff --git a/internal/db/models.go b/internal/db/models.go index 48c38f8..b26e2ce 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,29 +1,50 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db import ( + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type Game struct { - ID int64 + ID uuid.UUID + UserID uuid.UUID + PlatformID uuid.UUID Name string - PlatformID int64 Score int32 - ReleaseYear pgtype.Date + ReleaseDate pgtype.Date Finished pgtype.Date + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + ChangeSeq int64 } type Platform struct { - ID int64 - Name string + ID uuid.UUID + UserID uuid.UUID + Name string + UpdatedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + ChangeSeq int64 +} + +type Session struct { + TokenHash []byte + UserID uuid.UUID + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + LastSeenAt pgtype.Timestamptz } type User struct { - ID int64 - Name string - Bio pgtype.Text + ID uuid.UUID + Email string + PasswordHash string + Name string + Bio pgtype.Text + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } diff --git a/internal/db/querier.go b/internal/db/querier.go index f1e8f05..b297975 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -1,27 +1,37 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db import ( "context" + + "github.com/google/uuid" ) type Querier interface { - CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) - CreatePlatform(ctx context.Context, name string) (Platform, error) - CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - DeleteGame(ctx context.Context, id int64) error - DeletePlatform(ctx context.Context, id int64) error - DeleteUser(ctx context.Context, id int64) error - GetGame(ctx context.Context, id int64) (Game, error) - GetUser(ctx context.Context, id int64) (User, error) - ListGames(ctx context.Context) ([]Game, error) - ListPlatforms(ctx context.Context) ([]Platform, error) - ListUsers(ctx context.Context) ([]User, error) - UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) - UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) + CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) + CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) + CreateSession(ctx context.Context, arg CreateSessionParams) error + CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) + DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) + // Soft delete, so the FK on game.platform_id is never touched and never + // blocks this on its own; the NOT EXISTS guard is what stops a platform + // that still has active games from being deleted. Call GetPlatform first to + // tell "not found" apart from "blocked by games" when this affects 0 rows. + DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) + DeleteSession(ctx context.Context, tokenHash []byte) error + GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) + GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) + GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) + GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) + GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) + ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) + ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) + TouchSession(ctx context.Context, tokenHash []byte) error + UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) + UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index f0ffc87..4d39be9 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: query.sql package db @@ -8,41 +8,61 @@ package db import ( "context" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) const createGame = `-- name: CreateGame :one -INSERT INTO game ( - name, platform_id, score, release_year, finished -) VALUES ( - $1, $2, $3, $4, $5 +WITH inserted AS ( + INSERT INTO game ( + user_id, platform_id, name, score, release_date, finished + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + RETURNING id, platform_id, name, score, release_date, finished ) -RETURNING id, name, platform_id, score, release_year, finished +SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name, + inserted.score, inserted.release_date, inserted.finished +FROM inserted +JOIN platform ON platform.id = inserted.platform_id ` type CreateGameParams struct { + UserID uuid.UUID + PlatformID uuid.UUID Name string - PlatformID int64 Score int32 - ReleaseYear pgtype.Date + ReleaseDate pgtype.Date Finished pgtype.Date } -func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { +type CreateGameRow struct { + ID uuid.UUID + Name string + PlatformID uuid.UUID + PlatformName string + Score int32 + ReleaseDate pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) { row := q.db.QueryRow(ctx, createGame, - arg.Name, + arg.UserID, arg.PlatformID, + arg.Name, arg.Score, - arg.ReleaseYear, + arg.ReleaseDate, arg.Finished, ) - var i Game + var i CreateGameRow err := row.Scan( &i.ID, &i.Name, &i.PlatformID, + &i.PlatformName, &i.Score, - &i.ReleaseYear, + &i.ReleaseDate, &i.Finished, ) return i, err @@ -50,122 +70,327 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e const createPlatform = `-- name: CreatePlatform :one INSERT INTO platform ( - name + user_id, name ) VALUES ( - $1 + $1, $2 ) RETURNING id, name ` -func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) { - row := q.db.QueryRow(ctx, createPlatform, name) - var i Platform +type CreatePlatformParams struct { + UserID uuid.UUID + Name string +} + +type CreatePlatformRow struct { + ID uuid.UUID + Name string +} + +func (q *Queries) CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) { + row := q.db.QueryRow(ctx, createPlatform, arg.UserID, arg.Name) + var i CreatePlatformRow err := row.Scan(&i.ID, &i.Name) return i, err } +const createSession = `-- name: CreateSession :exec +INSERT INTO sessions ( + token_hash, user_id, expires_at +) VALUES ( + $1, $2, $3 +) +` + +type CreateSessionParams struct { + TokenHash []byte + UserID uuid.UUID + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.UserID, arg.ExpiresAt) + return err +} + const createUser = `-- name: CreateUser :one INSERT INTO users ( - name, bio + email, password_hash, name, bio ) VALUES ( - $1, $2 + $1, $2, $3, $4 ) -RETURNING id, name, bio +RETURNING id, email, name, bio, created_at ` type CreateUserParams struct { - Name string - Bio pgtype.Text + Email string + PasswordHash string + Name string + Bio pgtype.Text } -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { - row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio) - var i User - err := row.Scan(&i.ID, &i.Name, &i.Bio) +type CreateUserRow struct { + ID uuid.UUID + Email string + Name string + Bio pgtype.Text + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Email, + arg.PasswordHash, + arg.Name, + arg.Bio, + ) + var i CreateUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.Bio, + &i.CreatedAt, + ) return i, err } -const deleteGame = `-- name: DeleteGame :exec -DELETE FROM game -WHERE id = $1 +const deleteGame = `-- name: DeleteGame :execrows +UPDATE game +SET deleted_at = now() +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL ` -func (q *Queries) DeleteGame(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, deleteGame, id) - return err +type DeleteGameParams struct { + ID uuid.UUID + UserID uuid.UUID } -const deletePlatform = `-- name: DeletePlatform :exec -DELETE FROM platform -WHERE id = $1 -` - -func (q *Queries) DeletePlatform(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, deletePlatform, id) - return err +func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteGame, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } -const deleteUser = `-- name: DeleteUser :exec -DELETE FROM users -WHERE id = $1 +const deletePlatform = `-- name: DeletePlatform :execrows +UPDATE platform +SET deleted_at = now() +WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM game + WHERE game.platform_id = platform.id AND game.deleted_at IS NULL + ) ` -func (q *Queries) DeleteUser(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, deleteUser, id) +type DeletePlatformParams struct { + ID uuid.UUID + UserID uuid.UUID +} + +// Soft delete, so the FK on game.platform_id is never touched and never +// blocks this on its own; the NOT EXISTS guard is what stops a platform +// that still has active games from being deleted. Call GetPlatform first to +// tell "not found" apart from "blocked by games" when this affects 0 rows. +func (q *Queries) DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) { + result, err := q.db.Exec(ctx, deletePlatform, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token_hash = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error { + _, err := q.db.Exec(ctx, deleteSession, tokenHash) return err } const getGame = `-- name: GetGame :one -SELECT id, name, platform_id, score, release_year, finished FROM game -WHERE id = $1 LIMIT 1 +SELECT game.id, game.name, game.platform_id, platform.name AS platform_name, + game.score, game.release_date, game.finished +FROM game +JOIN platform ON platform.id = game.platform_id +WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL ` -func (q *Queries) GetGame(ctx context.Context, id int64) (Game, error) { - row := q.db.QueryRow(ctx, getGame, id) - var i Game +type GetGameParams struct { + ID uuid.UUID + UserID uuid.UUID +} + +type GetGameRow struct { + ID uuid.UUID + Name string + PlatformID uuid.UUID + PlatformName string + Score int32 + ReleaseDate pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) { + row := q.db.QueryRow(ctx, getGame, arg.ID, arg.UserID) + var i GetGameRow err := row.Scan( &i.ID, &i.Name, &i.PlatformID, + &i.PlatformName, &i.Score, - &i.ReleaseYear, + &i.ReleaseDate, &i.Finished, ) return i, err } -const getUser = `-- name: GetUser :one -SELECT id, name, bio FROM users -WHERE id = $1 LIMIT 1 +const getPlatform = `-- name: GetPlatform :one +SELECT id, name +FROM platform +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL ` -func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { - row := q.db.QueryRow(ctx, getUser, id) - var i User - err := row.Scan(&i.ID, &i.Name, &i.Bio) +type GetPlatformParams struct { + ID uuid.UUID + UserID uuid.UUID +} + +type GetPlatformRow struct { + ID uuid.UUID + Name string +} + +func (q *Queries) GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) { + row := q.db.QueryRow(ctx, getPlatform, arg.ID, arg.UserID) + var i GetPlatformRow + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const getSessionAndUser = `-- name: GetSessionAndUser :one +SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +FROM sessions +JOIN users ON users.id = sessions.user_id +WHERE sessions.token_hash = $1 AND sessions.expires_at > now() +` + +type GetSessionAndUserRow struct { + ID uuid.UUID + Email string + Name string + Bio pgtype.Text + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) { + row := q.db.QueryRow(ctx, getSessionAndUser, tokenHash) + var i GetSessionAndUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.Bio, + &i.ExpiresAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, name, bio, created_at +FROM users +WHERE email = $1 +` + +type GetUserByEmailRow struct { + ID uuid.UUID + Email string + PasswordHash string + Name string + Bio pgtype.Text + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i GetUserByEmailRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Name, + &i.Bio, + &i.CreatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, name, bio, created_at +FROM users +WHERE id = $1 +` + +type GetUserByIDRow struct { + ID uuid.UUID + Email string + Name string + Bio pgtype.Text + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.Bio, + &i.CreatedAt, + ) return i, err } const listGames = `-- name: ListGames :many -SELECT id, name, platform_id, score, release_year, finished FROM game -ORDER BY name +SELECT game.id, game.name, game.platform_id, platform.name AS platform_name, + game.score, game.release_date, game.finished +FROM game +JOIN platform ON platform.id = game.platform_id +WHERE game.user_id = $1 AND game.deleted_at IS NULL +ORDER BY game.name ` -func (q *Queries) ListGames(ctx context.Context) ([]Game, error) { - rows, err := q.db.Query(ctx, listGames) +type ListGamesRow struct { + ID uuid.UUID + Name string + PlatformID uuid.UUID + PlatformName string + Score int32 + ReleaseDate pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) { + rows, err := q.db.Query(ctx, listGames, userID) if err != nil { return nil, err } defer rows.Close() - var items []Game + var items []ListGamesRow for rows.Next() { - var i Game + var i ListGamesRow if err := rows.Scan( &i.ID, &i.Name, &i.PlatformID, + &i.PlatformName, &i.Score, - &i.ReleaseYear, + &i.ReleaseDate, &i.Finished, ); err != nil { return nil, err @@ -179,19 +404,26 @@ func (q *Queries) ListGames(ctx context.Context) ([]Game, error) { } const listPlatforms = `-- name: ListPlatforms :many -SELECT id, name FROM platform +SELECT id, name +FROM platform +WHERE user_id = $1 AND deleted_at IS NULL ORDER BY name ` -func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) { - rows, err := q.db.Query(ctx, listPlatforms) +type ListPlatformsRow struct { + ID uuid.UUID + Name string +} + +func (q *Queries) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) { + rows, err := q.db.Query(ctx, listPlatforms, userID) if err != nil { return nil, err } defer rows.Close() - var items []Platform + var items []ListPlatformsRow for rows.Next() { - var i Platform + var i ListPlatformsRow if err := rows.Scan(&i.ID, &i.Name); err != nil { return nil, err } @@ -203,63 +435,68 @@ func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) { return items, nil } -const listUsers = `-- name: ListUsers :many -SELECT id, name, bio FROM users -ORDER BY name +const touchSession = `-- name: TouchSession :exec +UPDATE sessions +SET last_seen_at = now() +WHERE token_hash = $1 ` -func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { - rows, err := q.db.Query(ctx, listUsers) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error { + _, err := q.db.Exec(ctx, touchSession, tokenHash) + return err } const updateGame = `-- name: UpdateGame :one -UPDATE game -SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6 -WHERE id = $1 -RETURNING id, name, platform_id, score, release_year, finished +WITH updated AS ( + UPDATE game + SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7 + WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL + RETURNING id, platform_id, name, score, release_date, finished +) +SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name, + updated.score, updated.release_date, updated.finished +FROM updated +JOIN platform ON platform.id = updated.platform_id ` type UpdateGameParams struct { - ID int64 + ID uuid.UUID + UserID uuid.UUID + PlatformID uuid.UUID Name string - PlatformID int64 Score int32 - ReleaseYear pgtype.Date + ReleaseDate pgtype.Date Finished pgtype.Date } -func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) { +type UpdateGameRow struct { + ID uuid.UUID + Name string + PlatformID uuid.UUID + PlatformName string + Score int32 + ReleaseDate pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) { row := q.db.QueryRow(ctx, updateGame, arg.ID, - arg.Name, + arg.UserID, arg.PlatformID, + arg.Name, arg.Score, - arg.ReleaseYear, + arg.ReleaseDate, arg.Finished, ) - var i Game + var i UpdateGameRow err := row.Scan( &i.ID, &i.Name, &i.PlatformID, + &i.PlatformName, &i.Score, - &i.ReleaseYear, + &i.ReleaseDate, &i.Finished, ) return i, err @@ -267,19 +504,25 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e const updatePlatform = `-- name: UpdatePlatform :one UPDATE platform -SET name = $2 -WHERE id = $1 +SET name = $3 +WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL RETURNING id, name ` type UpdatePlatformParams struct { - ID int64 + ID uuid.UUID + UserID uuid.UUID + Name string +} + +type UpdatePlatformRow struct { + ID uuid.UUID Name string } -func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) { - row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name) - var i Platform +func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) { + row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name) + var i UpdatePlatformRow err := row.Scan(&i.ID, &i.Name) return i, err } diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..6903f92 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,58 @@ +// Package model holds the plain data types shared between the API and its +// clients. It has no dependency on Huma, Echo or the database, so clients +// can import it without pulling in the server. +package model + +// User is the public view of an account; it never carries the password hash. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` +} + +type RegisterInput struct { + Email string `json:"email" format:"email"` + Password string `json:"password" minLength:"8"` + Name string `json:"name" minLength:"1"` +} + +type LoginInput struct { + Email string `json:"email" format:"email"` + Password string `json:"password"` +} + +// AuthResponse is returned by register and login. Token is also set as an +// httpOnly cookie, but is included in the body so non-browser clients (the +// TUI, the Fyne GUI) can store and send it themselves. +type AuthResponse struct { + Token string `json:"token"` + User User `json:"user"` +} + +type Platform struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type PlatformInput struct { + Name string `json:"name" minLength:"1" doc:"Name of the platform"` +} + +type Game struct { + ID string `json:"id"` + Name string `json:"name"` + PlatformID string `json:"platform_id"` + PlatformName string `json:"platform_name"` + Score int32 `json:"score"` + ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} + +type GameInput struct { + Name string `json:"name" minLength:"1"` + PlatformID string `json:"platform_id"` + Score int32 `json:"score" minimum:"0" maximum:"100"` + ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} diff --git a/sqlc.yaml b/sqlc.yaml index 6dd3c49..a29b8b4 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,6 +1,6 @@ version: "2" sql: - - schema: "db/schema.sql" + - schema: "db/migrations" queries: "db/query.sql" engine: "postgresql" gen: @@ -9,3 +9,6 @@ sql: out: "internal/db" sql_package: "pgx/v5" emit_interface: true + overrides: + - db_type: "uuid" + go_type: "github.com/google/uuid.UUID" From 7fbd403d3019b1d73e9ae6df8264194c8287350f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 11:34:47 +0200 Subject: [PATCH 2/5] Make the platform list shared across users; keep games private Platforms no longer have a user_id: there's one global list, unique by name, that every authenticated user reads from and writes to. Games are unchanged - still private per user, still referencing platform_id. - Migration: dropped platform.user_id and its FK, UNIQUE(user_id, name) -> UNIQUE(name), index on change_seq alone instead of (user_id, change_seq). Amended the one init migration in place rather than stacking a second one, since nothing is deployed yet. - db/query.sql: platform queries no longer take a user_id parameter. DeletePlatform's NOT EXISTS guard already checked games from any user (it never filtered the subquery by user_id), so deleting a shared platform is correctly blocked by any user's games on it, not just the deleter's. - routes.go: platform handlers still require auth (the list isn't public to anonymous requests) but no longer scope by user. - Tests: updated the mocks for the new signatures, added TestListPlatformsIsSharedAcrossUsers. Verified against a real Postgres: user A creates "PC", user B sees it immediately, both can add games against it, each only sees their own games, deleting the platform is blocked while either user has a game on it, and the name stays globally unique. Co-Authored-By: Claude Sonnet 5 --- db/migrations/20261003000000_init.up.sql | 7 +-- db/query.sql | 19 ++++---- internal/api/mock_db_test.go | 24 +++++----- internal/api/routes.go | 24 +++++----- internal/api/routes_test.go | 34 +++++++++++--- internal/db/models.go | 1 - internal/db/querier.go | 13 +++--- internal/db/query.sql.go | 57 +++++++++--------------- 8 files changed, 93 insertions(+), 86 deletions(-) diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql index fa5be1c..345dbba 100644 --- a/db/migrations/20261003000000_init.up.sql +++ b/db/migrations/20261003000000_init.up.sql @@ -28,17 +28,18 @@ CREATE TABLE sessions ( CREATE INDEX idx_sessions_user_id ON sessions(user_id); +-- Platforms are shared: every user picks from and adds to the same list, +-- unlike games, which are private per user_id. CREATE TABLE platform ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, name text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq'), - UNIQUE (user_id, name) + UNIQUE (name) ); -CREATE INDEX idx_platform_user_change_seq ON platform(user_id, change_seq); +CREATE INDEX idx_platform_change_seq ON platform(change_seq); CREATE TABLE game ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/db/query.sql b/db/query.sql index a13bd9f..a19efd9 100644 --- a/db/query.sql +++ b/db/query.sql @@ -40,37 +40,38 @@ WHERE token_hash = $1; -- name: CreatePlatform :one INSERT INTO platform ( - user_id, name + name ) VALUES ( - $1, $2 + $1 ) RETURNING id, name; -- name: GetPlatform :one SELECT id, name FROM platform -WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL; +WHERE id = $1 AND deleted_at IS NULL; -- name: ListPlatforms :many SELECT id, name FROM platform -WHERE user_id = $1 AND deleted_at IS NULL +WHERE deleted_at IS NULL ORDER BY name; -- name: UpdatePlatform :one UPDATE platform -SET name = $3 -WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL +SET name = $2 +WHERE id = $1 AND deleted_at IS NULL RETURNING id, name; -- name: DeletePlatform :execrows -- Soft delete, so the FK on game.platform_id is never touched and never -- blocks this on its own; the NOT EXISTS guard is what stops a platform --- that still has active games from being deleted. Call GetPlatform first to --- tell "not found" apart from "blocked by games" when this affects 0 rows. +-- that still has active games - from any user, since the list is shared - +-- from being deleted. Call GetPlatform first to tell "not found" apart from +-- "blocked by games" when this affects 0 rows. UPDATE platform SET deleted_at = now() -WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL +WHERE platform.id = $1 AND platform.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM game WHERE game.platform_id = platform.id AND game.deleted_at IS NULL diff --git a/internal/api/mock_db_test.go b/internal/api/mock_db_test.go index ec1e30a..9592118 100644 --- a/internal/api/mock_db_test.go +++ b/internal/api/mock_db_test.go @@ -15,19 +15,19 @@ import ( // pointer deep in sqlc-generated code. type MockQuerier struct { CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) - CreatePlatformFunc func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) + CreatePlatformFunc func(ctx context.Context, name string) (db.CreatePlatformRow, error) CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error) - DeletePlatformFunc func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) + DeletePlatformFunc func(ctx context.Context, id uuid.UUID) (int64, error) DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) - GetPlatformFunc func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) + GetPlatformFunc func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error) GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) ListGamesFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) - ListPlatformsFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) + ListPlatformsFunc func(ctx context.Context) ([]db.ListPlatformsRow, error) TouchSessionFunc func(ctx context.Context, tokenHash []byte) error UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) @@ -36,8 +36,8 @@ type MockQuerier struct { func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) { return m.CreateGameFunc(ctx, arg) } -func (m *MockQuerier) CreatePlatform(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) { - return m.CreatePlatformFunc(ctx, arg) +func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.CreatePlatformRow, error) { + return m.CreatePlatformFunc(ctx, name) } func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error { return m.CreateSessionFunc(ctx, arg) @@ -48,8 +48,8 @@ func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) ( func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) { return m.DeleteGameFunc(ctx, arg) } -func (m *MockQuerier) DeletePlatform(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { - return m.DeletePlatformFunc(ctx, arg) +func (m *MockQuerier) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) { + return m.DeletePlatformFunc(ctx, id) } func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error { return m.DeleteSessionFunc(ctx, tokenHash) @@ -57,8 +57,8 @@ func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { return m.GetGameFunc(ctx, arg) } -func (m *MockQuerier) GetPlatform(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { - return m.GetPlatformFunc(ctx, arg) +func (m *MockQuerier) GetPlatform(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { + return m.GetPlatformFunc(ctx, id) } func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { return m.GetSessionAndUserFunc(ctx, tokenHash) @@ -72,8 +72,8 @@ func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUser func (m *MockQuerier) ListGames(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) { return m.ListGamesFunc(ctx, userID) } -func (m *MockQuerier) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]db.ListPlatformsRow, error) { - return m.ListPlatformsFunc(ctx, userID) +func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.ListPlatformsRow, error) { + return m.ListPlatformsFunc(ctx) } func (m *MockQuerier) TouchSession(ctx context.Context, tokenHash []byte) error { return m.TouchSessionFunc(ctx, tokenHash) diff --git a/internal/api/routes.go b/internal/api/routes.go index 7ff8685..19e6da0 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -41,6 +41,11 @@ func RegisterRoutes(api huma.API, queries db.Querier) { } // --- Platform Routes --- +// +// Platforms are a shared list: every authenticated user reads and writes +// the same rows, unlike games, which are private per user. Routes still +// require auth (so the list isn't public), but none of the queries below +// filter by user_id, because platform has no such column. func registerPlatformRoutes(api huma.API, queries db.Querier) { listOp := huma.Operation{ @@ -51,7 +56,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { } addAuth(&listOp, api, queries) huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) { - platforms, err := queries.ListPlatforms(ctx, userFromContext(ctx).ID) + platforms, err := queries.ListPlatforms(ctx) if err != nil { return nil, dbError(err) } @@ -72,7 +77,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { huma.Register(api, getOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` }) (*struct{ Body model.Platform }, error) { - p, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userFromContext(ctx).ID}) + p, err := queries.GetPlatform(ctx, input.ID) if err != nil { return nil, dbError(err) } @@ -87,10 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { } addAuth(&createOp, api, queries) huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { - platform, err := queries.CreatePlatform(ctx, db.CreatePlatformParams{ - UserID: userFromContext(ctx).ID, - Name: input.Body.Name, - }) + platform, err := queries.CreatePlatform(ctx, input.Body.Name) if err != nil { return nil, dbError(err) } @@ -109,9 +111,8 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{ - ID: input.ID, - UserID: userFromContext(ctx).ID, - Name: input.Body.Name, + ID: input.ID, + Name: input.Body.Name, }) if err != nil { return nil, dbError(err) @@ -129,7 +130,6 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { huma.Register(api, deleteOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` }) (*struct{}, error) { - userID := userFromContext(ctx).ID // Checked separately so a 0-rows result from DeletePlatform below can // only mean "blocked by games referencing it", not "doesn't exist" - // soft delete means the FK alone can't tell the two apart (see @@ -137,11 +137,11 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { // the platform is deleted between these two queries; harmless, since // the second query would then just affect 0 rows and report 409 // instead of the 404 a plain retry would hit anyway. - if _, err := queries.GetPlatform(ctx, db.GetPlatformParams{ID: input.ID, UserID: userID}); err != nil { + if _, err := queries.GetPlatform(ctx, input.ID); err != nil { return nil, dbError(err) } - rows, err := queries.DeletePlatform(ctx, db.DeletePlatformParams{ID: input.ID, UserID: userID}) + rows, err := queries.DeletePlatform(ctx, input.ID) if err != nil { return nil, dbError(err) } diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 1ddd215..713e365 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -119,10 +119,10 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { // the 0-rows result itself and turn it into 409, not just pass through // whatever error (if any) the query returns. mockDB := authedMock() - mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { - return db.GetPlatformRow{ID: arg.ID, Name: "PC"}, nil + mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { + return db.GetPlatformRow{ID: id, Name: "PC"}, nil } - mockDB.DeletePlatformFunc = func(ctx context.Context, arg db.DeletePlatformParams) (int64, error) { + mockDB.DeletePlatformFunc = func(ctx context.Context, id uuid.UUID) (int64, error) { return 0, nil } @@ -135,7 +135,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { func TestDeletePlatformNotFound(t *testing.T) { mockDB := authedMock() - mockDB.GetPlatformFunc = func(ctx context.Context, arg db.GetPlatformParams) (db.GetPlatformRow, error) { + mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{}, pgx.ErrNoRows } @@ -161,9 +161,8 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { func TestCreatePlatform(t *testing.T) { mockDB := authedMock() - mockDB.CreatePlatformFunc = func(ctx context.Context, arg db.CreatePlatformParams) (db.CreatePlatformRow, error) { - assert.Equal(t, testUserID, arg.UserID) - return db.CreatePlatformRow{ID: uuid.New(), Name: arg.Name}, nil + mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) { + return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil } e, _ := newTestAPI(mockDB) @@ -174,6 +173,27 @@ func TestCreatePlatform(t *testing.T) { assert.Contains(t, rec.Body.String(), "Sega Genesis") } +func TestListPlatformsIsSharedAcrossUsers(t *testing.T) { + // Unlike games, the platform list has no user_id to scope by: every + // authenticated user sees the same rows. ListPlatformsFunc taking no + // user id (beyond ctx) is itself part of what's being checked here - + // this test would fail to compile if a user_id parameter crept back in. + calls := 0 + mockDB := authedMock() + mockDB.ListPlatformsFunc = func(ctx context.Context) ([]db.ListPlatformsRow, error) { + calls++ + return []db.ListPlatformsRow{{ID: uuid.New(), Name: "PC"}}, nil + } + + e, _ := newTestAPI(mockDB) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/platforms", "")) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "PC") + assert.Equal(t, 1, calls) +} + func TestRegisterDuplicateEmailConflicts(t *testing.T) { mockDB := &MockQuerier{ CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { diff --git a/internal/db/models.go b/internal/db/models.go index b26e2ce..4f80886 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -24,7 +24,6 @@ type Game struct { type Platform struct { ID uuid.UUID - UserID uuid.UUID Name string UpdatedAt pgtype.Timestamptz DeletedAt pgtype.Timestamptz diff --git a/internal/db/querier.go b/internal/db/querier.go index b297975..05a6900 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -12,23 +12,24 @@ import ( type Querier interface { CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) - CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) + CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) CreateSession(ctx context.Context, arg CreateSessionParams) error CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) // Soft delete, so the FK on game.platform_id is never touched and never // blocks this on its own; the NOT EXISTS guard is what stops a platform - // that still has active games from being deleted. Call GetPlatform first to - // tell "not found" apart from "blocked by games" when this affects 0 rows. - DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) + // that still has active games - from any user, since the list is shared - + // from being deleted. Call GetPlatform first to tell "not found" apart from + // "blocked by games" when this affects 0 rows. + DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) DeleteSession(ctx context.Context, tokenHash []byte) error GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) - GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) + GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) - ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) + ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error) TouchSession(ctx context.Context, tokenHash []byte) error UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 4d39be9..aed90ae 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -70,25 +70,20 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateG const createPlatform = `-- name: CreatePlatform :one INSERT INTO platform ( - user_id, name + name ) VALUES ( - $1, $2 + $1 ) RETURNING id, name ` -type CreatePlatformParams struct { - UserID uuid.UUID - Name string -} - type CreatePlatformRow struct { ID uuid.UUID Name string } -func (q *Queries) CreatePlatform(ctx context.Context, arg CreatePlatformParams) (CreatePlatformRow, error) { - row := q.db.QueryRow(ctx, createPlatform, arg.UserID, arg.Name) +func (q *Queries) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) { + row := q.db.QueryRow(ctx, createPlatform, name) var i CreatePlatformRow err := row.Scan(&i.ID, &i.Name) return i, err @@ -177,24 +172,20 @@ func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, const deletePlatform = `-- name: DeletePlatform :execrows UPDATE platform SET deleted_at = now() -WHERE platform.id = $1 AND platform.user_id = $2 AND platform.deleted_at IS NULL +WHERE platform.id = $1 AND platform.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM game WHERE game.platform_id = platform.id AND game.deleted_at IS NULL ) ` -type DeletePlatformParams struct { - ID uuid.UUID - UserID uuid.UUID -} - // Soft delete, so the FK on game.platform_id is never touched and never // blocks this on its own; the NOT EXISTS guard is what stops a platform -// that still has active games from being deleted. Call GetPlatform first to -// tell "not found" apart from "blocked by games" when this affects 0 rows. -func (q *Queries) DeletePlatform(ctx context.Context, arg DeletePlatformParams) (int64, error) { - result, err := q.db.Exec(ctx, deletePlatform, arg.ID, arg.UserID) +// that still has active games - from any user, since the list is shared - +// from being deleted. Call GetPlatform first to tell "not found" apart from +// "blocked by games" when this affects 0 rows. +func (q *Queries) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, deletePlatform, id) if err != nil { return 0, err } @@ -252,21 +243,16 @@ func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, e const getPlatform = `-- name: GetPlatform :one SELECT id, name FROM platform -WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL +WHERE id = $1 AND deleted_at IS NULL ` -type GetPlatformParams struct { - ID uuid.UUID - UserID uuid.UUID -} - type GetPlatformRow struct { ID uuid.UUID Name string } -func (q *Queries) GetPlatform(ctx context.Context, arg GetPlatformParams) (GetPlatformRow, error) { - row := q.db.QueryRow(ctx, getPlatform, arg.ID, arg.UserID) +func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error) { + row := q.db.QueryRow(ctx, getPlatform, id) var i GetPlatformRow err := row.Scan(&i.ID, &i.Name) return i, err @@ -406,7 +392,7 @@ func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesR const listPlatforms = `-- name: ListPlatforms :many SELECT id, name FROM platform -WHERE user_id = $1 AND deleted_at IS NULL +WHERE deleted_at IS NULL ORDER BY name ` @@ -415,8 +401,8 @@ type ListPlatformsRow struct { Name string } -func (q *Queries) ListPlatforms(ctx context.Context, userID uuid.UUID) ([]ListPlatformsRow, error) { - rows, err := q.db.Query(ctx, listPlatforms, userID) +func (q *Queries) ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error) { + rows, err := q.db.Query(ctx, listPlatforms) if err != nil { return nil, err } @@ -504,15 +490,14 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateG const updatePlatform = `-- name: UpdatePlatform :one UPDATE platform -SET name = $3 -WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL +SET name = $2 +WHERE id = $1 AND deleted_at IS NULL RETURNING id, name ` type UpdatePlatformParams struct { - ID uuid.UUID - UserID uuid.UUID - Name string + ID uuid.UUID + Name string } type UpdatePlatformRow struct { @@ -521,7 +506,7 @@ type UpdatePlatformRow struct { } func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) { - row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.UserID, arg.Name) + row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name) var i UpdatePlatformRow err := row.Scan(&i.ID, &i.Name) return i, err From 384c4a72170bc137290148f247245e5355fbdab6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 11:39:21 +0200 Subject: [PATCH 3/5] Restrict platform-list edits to admins Anyone authenticated can still read the shared platform list, but only an admin may add, rename or delete from it. - users.is_admin boolean, defaulting to false. The very first account ever registered becomes an admin automatically (CreateUser's INSERT uses NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at least one without manual setup; promoting anyone beyond that is a manual UPDATE for now (documented in the migration's comment on the column). - GetSessionAndUser, GetUserByEmail and GetUserByID now also select is_admin, and it flows through to model.User (and so to /api/auth/{register,login,me}'s response) and to the auth middleware's authedUser. - New requireAdminMiddleware + addAdminAuth, layered on top of the existing auth middleware. Wired onto create/update/delete-platform; list/get stay on plain addAuth, since reading the list only requires being logged in. Verified end to end: the first registered user comes back is_admin:true, the second false; the non-admin gets 403 on create/update/delete-platform but 200 on list; the admin can do all of it. Co-Authored-By: Claude Sonnet 5 --- api/openapi.json | 2 +- db/migrations/20261003000000_init.up.sql | 7 ++++++ db/query.sql | 15 ++++++++----- internal/api/auth_routes.go | 4 ++-- internal/api/context.go | 11 +++++----- internal/api/middleware.go | 26 +++++++++++++++++++++- internal/api/routes.go | 16 ++++++++------ internal/api/routes_test.go | 28 +++++++++++++++++++++--- internal/db/models.go | 1 + internal/db/querier.go | 3 +++ internal/db/query.sql.go | 23 ++++++++++++++----- internal/model/model.go | 9 ++++---- 12 files changed, 110 insertions(+), 35 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 8be1d82..4a277ef 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","email","name"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} +{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"is_admin":{"type":"boolean"},"name":{"type":"string"}},"required":["id","email","name","is_admin"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql index 345dbba..0121f53 100644 --- a/db/migrations/20261003000000_init.up.sql +++ b/db/migrations/20261003000000_init.up.sql @@ -14,6 +14,13 @@ CREATE TABLE users ( password_hash text NOT NULL, name text NOT NULL, bio text, + -- Only admins may add/rename/delete from the shared platform list (see + -- the platform table below); everyone can read it. The first account + -- ever registered becomes an admin automatically (see CreateUser in + -- db/query.sql) so there's always at least one without manual setup; + -- promoting anyone beyond that is a manual + -- `UPDATE users SET is_admin = true WHERE email = ...` for now. + is_admin boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); diff --git a/db/query.sql b/db/query.sql index a19efd9..a1c84a3 100644 --- a/db/query.sql +++ b/db/query.sql @@ -1,18 +1,21 @@ -- name: CreateUser :one +-- The first account ever registered becomes an admin automatically; see +-- the is_admin column's comment in db/migrations for why and the race this +-- accepts. Everyone after that registers as a non-admin. INSERT INTO users ( - email, password_hash, name, bio + email, password_hash, name, bio, is_admin ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users) ) -RETURNING id, email, name, bio, created_at; +RETURNING id, email, name, bio, is_admin, created_at; -- name: GetUserByEmail :one -SELECT id, email, password_hash, name, bio, created_at +SELECT id, email, password_hash, name, bio, is_admin, created_at FROM users WHERE email = $1; -- name: GetUserByID :one -SELECT id, email, name, bio, created_at +SELECT id, email, name, bio, is_admin, created_at FROM users WHERE id = $1; @@ -24,7 +27,7 @@ INSERT INTO sessions ( ); -- name: GetSessionAndUser :one -SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token_hash = $1 AND sessions.expires_at > now(); diff --git a/internal/api/auth_routes.go b/internal/api/auth_routes.go index a74175c..4e66c1c 100644 --- a/internal/api/auth_routes.go +++ b/internal/api/auth_routes.go @@ -93,7 +93,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, - User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) @@ -126,7 +126,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, - User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) diff --git a/internal/api/context.go b/internal/api/context.go index 2540fe7..f2255d5 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -11,10 +11,11 @@ import ( // authedUser is what AuthMiddleware stores in the request context after // validating a session token. Handlers read it with userFromContext. type authedUser struct { - ID uuid.UUID - Email string - Name string - Bio string + ID uuid.UUID + Email string + Name string + Bio string + IsAdmin bool } type ctxKey string @@ -22,7 +23,7 @@ type ctxKey string const userCtxKey ctxKey = "completed.user" func (u authedUser) toModel() model.User { - return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio} + return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio, IsAdmin: u.IsAdmin} } // userFromContext returns the authenticated user set by AuthMiddleware. It diff --git a/internal/api/middleware.go b/internal/api/middleware.go index ba0998c..5f3a83b 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -64,7 +64,7 @@ func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func // Best-effort; a failure to record activity shouldn't fail the request. _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) - user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} + user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin} newCtx := huma.WithValue(ctx, userCtxKey, user) newCtx = huma.WithValue(newCtx, tokenCtxKey, token) next(newCtx) @@ -77,6 +77,30 @@ func addAuth(op *huma.Operation, api huma.API, queries db.Querier) { op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) } +// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and +// rejects the request unless the authenticated user is an admin. It reads +// the context value that middleware set directly, rather than calling +// userFromContext, so a wiring mistake (this middleware attached without +// the auth one ahead of it) fails the request with 403 instead of panicking. +func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + u, ok := ctx.Context().Value(userCtxKey).(authedUser) + if !ok || !u.IsAdmin { + huma.WriteErr(api, ctx, 403, "admin access required") + return + } + next(ctx) + } +} + +// addAdminAuth attaches both the auth and the admin-only middleware to op, +// for routes that mutate data every user can see (the shared platform +// list) but only an admin should be able to change. +func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) { + addAuth(op, api, queries) + op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api)) +} + // tokenCtxKey holds the raw bearer/cookie token for the current request, so // the logout handler can hash and delete the exact session that was used // without re-parsing the request. diff --git a/internal/api/routes.go b/internal/api/routes.go index 19e6da0..1773fbe 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -42,10 +42,12 @@ func RegisterRoutes(api huma.API, queries db.Querier) { // --- Platform Routes --- // -// Platforms are a shared list: every authenticated user reads and writes -// the same rows, unlike games, which are private per user. Routes still -// require auth (so the list isn't public), but none of the queries below -// filter by user_id, because platform has no such column. +// Platforms are a shared list: every authenticated user reads the same +// rows, unlike games, which are private per user. None of the queries +// below filter by user_id, because platform has no such column. Reading +// the list only requires being logged in (addAuth); adding, renaming or +// deleting a platform requires being an admin (addAdminAuth), since that +// changes what everyone else sees. func registerPlatformRoutes(api huma.API, queries db.Querier) { listOp := huma.Operation{ @@ -90,7 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms", Summary: "Create Platform", } - addAuth(&createOp, api, queries) + addAdminAuth(&createOp, api, queries) huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { platform, err := queries.CreatePlatform(ctx, input.Body.Name) if err != nil { @@ -105,7 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms/{id}", Summary: "Update Platform", } - addAuth(&updateOp, api, queries) + addAdminAuth(&updateOp, api, queries) huma.Register(api, updateOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` Body model.PlatformInput @@ -126,7 +128,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms/{id}", Summary: "Delete Platform", } - addAuth(&deleteOp, api, queries) + addAdminAuth(&deleteOp, api, queries) huma.Register(api, deleteOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` }) (*struct{}, error) { diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 713e365..f4c8bab 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -43,6 +43,16 @@ func authedMock() *MockQuerier { } } +// adminMock is authedMock for a session belonging to an admin, for the +// platform-mutation routes that require one. +func adminMock() *MockQuerier { + m := authedMock() + m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil + } + return m +} + func authedRequest(method, path, body string) *http.Request { var req *http.Request if body == "" { @@ -118,7 +128,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { // db/query.sql's DeletePlatform comment), so the handler has to notice // the 0-rows result itself and turn it into 409, not just pass through // whatever error (if any) the query returns. - mockDB := authedMock() + mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{ID: id, Name: "PC"}, nil } @@ -134,7 +144,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { } func TestDeletePlatformNotFound(t *testing.T) { - mockDB := authedMock() + mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{}, pgx.ErrNoRows } @@ -160,7 +170,7 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { } func TestCreatePlatform(t *testing.T) { - mockDB := authedMock() + mockDB := adminMock() mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) { return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil } @@ -173,6 +183,18 @@ func TestCreatePlatform(t *testing.T) { assert.Contains(t, rec.Body.String(), "Sega Genesis") } +func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) { + mockDB := authedMock() + // No CreatePlatformFunc set: the admin check must reject the request + // before the handler ever calls the database, or this test would panic. + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) + + assert.Equal(t, http.StatusForbidden, rec.Code) +} + func TestListPlatformsIsSharedAcrossUsers(t *testing.T) { // Unlike games, the platform list has no user_id to scope by: every // authenticated user sees the same rows. ListPlatformsFunc taking no diff --git a/internal/db/models.go b/internal/db/models.go index 4f80886..f8c52ab 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -44,6 +44,7 @@ type User struct { PasswordHash string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } diff --git a/internal/db/querier.go b/internal/db/querier.go index 05a6900..4293629 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -14,6 +14,9 @@ type Querier interface { CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) CreateSession(ctx context.Context, arg CreateSessionParams) error + // The first account ever registered becomes an admin automatically; see + // the is_admin column's comment in db/migrations for why and the race this + // accepts. Everyone after that registers as a non-admin. CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) // Soft delete, so the FK on game.platform_id is never touched and never diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index aed90ae..c76f01a 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -110,11 +110,11 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er const createUser = `-- name: CreateUser :one INSERT INTO users ( - email, password_hash, name, bio + email, password_hash, name, bio, is_admin ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users) ) -RETURNING id, email, name, bio, created_at +RETURNING id, email, name, bio, is_admin, created_at ` type CreateUserParams struct { @@ -129,9 +129,13 @@ type CreateUserRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } +// The first account ever registered becomes an admin automatically; see +// the is_admin column's comment in db/migrations for why and the race this +// accepts. Everyone after that registers as a non-admin. func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { row := q.db.QueryRow(ctx, createUser, arg.Email, @@ -145,6 +149,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err @@ -259,7 +264,7 @@ func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow } const getSessionAndUser = `-- name: GetSessionAndUser :one -SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token_hash = $1 AND sessions.expires_at > now() @@ -270,6 +275,7 @@ type GetSessionAndUserRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool ExpiresAt pgtype.Timestamptz } @@ -281,13 +287,14 @@ func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetS &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.ExpiresAt, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, name, bio, created_at +SELECT id, email, password_hash, name, bio, is_admin, created_at FROM users WHERE email = $1 ` @@ -298,6 +305,7 @@ type GetUserByEmailRow struct { PasswordHash string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } @@ -310,13 +318,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm &i.PasswordHash, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, name, bio, created_at +SELECT id, email, name, bio, is_admin, created_at FROM users WHERE id = $1 ` @@ -326,6 +335,7 @@ type GetUserByIDRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } @@ -337,6 +347,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err diff --git a/internal/model/model.go b/internal/model/model.go index 6903f92..a70763f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -5,10 +5,11 @@ package model // User is the public view of an account; it never carries the password hash. type User struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Bio string `json:"bio,omitempty"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` + IsAdmin bool `json:"is_admin"` } type RegisterInput struct { From 9972537b63fe94a7c51686a2ec3a94ecd2680ab2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 12:16:59 +0200 Subject: [PATCH 4/5] Add a small, tested base for all three clients, and Gitea CI/release workflows internal/client (new): a typed Go HTTP client for the API - login, register, logout, me, and full CRUD for games/platforms - shared by the TUI and the Fyne GUI so request-building, auth and error handling (an APIError with the HTTP status) live in one tested place instead of being duplicated. 8 tests against an httptest server covering success, 401, 403 and that the token is stored/cleared at the right times. cmd/tui (rewrite): drops the old unauthenticated read-only viewer. Now a login screen (bubbles/textinput) -> a scrollable games table (bubbles/table) with 'r' to refresh, built as an explicit state machine (screenLogin/screenLoading/screenGames) so Update's transitions are testable without a real terminal. 9 tests driving Update() directly. cmd/gui (rewrite): drops raylib for Fyne. Same shape as the TUI - a login form, then a games table, with networking on a goroutine and fyne.Do for the UI updates. The table-cell and login-validation logic are plain functions with no Fyne/display dependency, so they're tested directly (7 tests) rather than needing Fyne's headless test driver. internal/api: tagged every operation (Auth/Platforms/Games/Misc) so the generated OpenAPI spec groups into named services instead of one bucket; api/openapi.json regenerated. Added CORS middleware (wildcard origin, no credentials needed since clients use a bearer token, not the cookie) - needed for the new web client's browser requests to the API's a different origin. web/ (new): an Angular 22 + Material app, API client generated from api/openapi.json via ng-openapi-gen (services: true for per-resource classes - AuthService/GamesService/PlatformsService - instead of one generic Api facade). A Session service holds the token/user as signals, persists the token to localStorage, and is the only thing that calls the generated AuthService; an HttpInterceptorFn attaches the bearer token to every request; a CanActivateFn guard protects /games. Login and Games are standalone, lazy-loaded route components. 21 tests (Vitest, Angular's new default - no browser/Karma needed) covering the service, interceptor, guard and both components, run via `npm test` exactly as CI will call it. Justfile: fixed run-tui/run-gui, which used `go run .../main.go` - broken now that both packages span multiple files, since that only compiles the one named file. Added web-install/web-dev/web-build/web-test. .gitea/workflows/ci.yml (moved from .github, which Gitea doesn't read): on push to main/develop - gofmt, go vet, go build, go test, go mod tidy diff (with the Fyne build's system deps installed), sqlc diff, and the web app's build+test. .gitea/workflows/release.yml (new): on a vX.Y.Z tag, cross-compiles api+tui for linux/darwin/windows (amd64+arm64, pure Go, no cgo), builds the Fyne GUI for linux/amd64 only (cgo + its OpenGL/X11 link means it can't cross-compile from one Linux runner the way api/tui do, and this repo has no registered macOS/Windows runner to build it on natively - documented in the workflow's comments), builds the web app, and creates a Gitea release with all of it attached via the Gitea API. Written to Gitea's documented GitHub-Actions-compatible syntax but not executable from here - there's no Gitea server in this environment to run it against, unlike everything else in this commit, which was. Verified: every Go package builds/vets/tests clean; sqlc diff and go mod tidy -diff are clean; the Angular app builds for production and its test suite passes via `npm test`/`npm run build` (the exact commands CI runs), including after a clean `npm ci` from the committed lockfile; the new CORS config was checked with real preflight and GET requests against a running server, not just read. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/ci.yml | 81 + .gitea/workflows/release.yml | 168 + .github/workflows/ci.yml | 39 - Justfile | 26 +- README.md | 51 +- api/openapi.json | 2 +- cmd/api/main.go | 9 + cmd/gui/app.go | 117 + cmd/gui/main.go | 147 +- cmd/gui/table.go | 44 + cmd/gui/table_test.go | 59 + cmd/gui/validate.go | 16 + cmd/gui/validate_test.go | 23 + cmd/tui/main.go | 142 +- cmd/tui/model.go | 298 + cmd/tui/model_test.go | 179 + go.mod | 33 +- go.sum | 72 +- internal/api/auth_routes.go | 4 + internal/api/routes.go | 11 + internal/client/client.go | 218 + internal/client/client_test.go | 186 + web/.editorconfig | 17 + web/.gitignore | 44 + web/.prettierrc | 12 + web/.vscode/extensions.json | 4 + web/.vscode/launch.json | 20 + web/.vscode/tasks.json | 42 + web/README.md | 59 + web/angular.json | 71 + web/ng-openapi-gen.json | 7 + web/package-lock.json | 7131 +++++++++++++++++ web/package.json | 36 + web/public/favicon.ico | Bin 0 -> 15086 bytes web/src/app/api/api-configuration.ts | 27 + web/src/app/api/api.ts | 64 + web/src/app/api/base-service.ts | 35 + web/src/app/api/fn/auth/get-me.ts | 30 + web/src/app/api/fn/auth/login.ts | 33 + web/src/app/api/fn/auth/logout.ts | 29 + web/src/app/api/fn/auth/register.ts | 33 + web/src/app/api/fn/games/create-game.ts | 33 + web/src/app/api/fn/games/delete-game.ts | 31 + web/src/app/api/fn/games/get-game.ts | 32 + web/src/app/api/fn/games/list-games.ts | 30 + web/src/app/api/fn/games/update-game.ts | 35 + web/src/app/api/fn/misc/get-hello.ts | 30 + .../app/api/fn/platforms/create-platform.ts | 33 + .../app/api/fn/platforms/delete-platform.ts | 31 + web/src/app/api/fn/platforms/get-platform.ts | 32 + .../app/api/fn/platforms/list-platforms.ts | 30 + .../app/api/fn/platforms/update-platform.ts | 35 + web/src/app/api/functions.ts | 33 + web/src/app/api/models.ts | 14 + web/src/app/api/models/auth-response.ts | 13 + web/src/app/api/models/error-detail.ts | 20 + web/src/app/api/models/error-model.ts | 41 + web/src/app/api/models/game-input.ts | 23 + web/src/app/api/models/game.ts | 25 + web/src/app/api/models/get-hello-response.ts | 11 + web/src/app/api/models/login-input.ts | 12 + web/src/app/api/models/platform-input.ts | 15 + web/src/app/api/models/platform.ts | 12 + web/src/app/api/models/register-input.ts | 13 + web/src/app/api/models/user.ts | 15 + web/src/app/api/request-builder.ts | 369 + web/src/app/api/services.ts | 7 + web/src/app/api/services/auth.service.ts | 161 + web/src/app/api/services/games.service.ts | 195 + web/src/app/api/services/misc.service.ts | 55 + web/src/app/api/services/platforms.service.ts | 195 + web/src/app/api/strict-http-response.ts | 11 + web/src/app/app.config.ts | 24 + web/src/app/app.routes.ts | 16 + web/src/app/app.spec.ts | 18 + web/src/app/app.ts | 9 + web/src/app/core/auth-guard.spec.ts | 43 + web/src/app/core/auth-guard.ts | 12 + web/src/app/core/auth-interceptor.spec.ts | 45 + web/src/app/core/auth-interceptor.ts | 13 + web/src/app/core/session.spec.ts | 90 + web/src/app/core/session.ts | 92 + web/src/app/features/games/games.css | 26 + web/src/app/features/games/games.html | 51 + web/src/app/features/games/games.spec.ts | 109 + web/src/app/features/games/games.ts | 58 + web/src/app/features/login/login.css | 18 + web/src/app/features/login/login.html | 32 + web/src/app/features/login/login.spec.ts | 84 + web/src/app/features/login/login.ts | 68 + web/src/index.html | 23 + web/src/main.ts | 6 + web/src/material-theme.scss | 38 + web/src/styles.css | 1 + web/tsconfig.app.json | 14 + web/tsconfig.json | 31 + web/tsconfig.spec.json | 14 + 97 files changed, 11810 insertions(+), 336 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 cmd/gui/app.go create mode 100644 cmd/gui/table.go create mode 100644 cmd/gui/table_test.go create mode 100644 cmd/gui/validate.go create mode 100644 cmd/gui/validate_test.go create mode 100644 cmd/tui/model.go create mode 100644 cmd/tui/model_test.go create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 web/.editorconfig create mode 100644 web/.gitignore create mode 100644 web/.prettierrc create mode 100644 web/.vscode/extensions.json create mode 100644 web/.vscode/launch.json create mode 100644 web/.vscode/tasks.json create mode 100644 web/README.md create mode 100644 web/angular.json create mode 100644 web/ng-openapi-gen.json create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.ico create mode 100644 web/src/app/api/api-configuration.ts create mode 100644 web/src/app/api/api.ts create mode 100644 web/src/app/api/base-service.ts create mode 100644 web/src/app/api/fn/auth/get-me.ts create mode 100644 web/src/app/api/fn/auth/login.ts create mode 100644 web/src/app/api/fn/auth/logout.ts create mode 100644 web/src/app/api/fn/auth/register.ts create mode 100644 web/src/app/api/fn/games/create-game.ts create mode 100644 web/src/app/api/fn/games/delete-game.ts create mode 100644 web/src/app/api/fn/games/get-game.ts create mode 100644 web/src/app/api/fn/games/list-games.ts create mode 100644 web/src/app/api/fn/games/update-game.ts create mode 100644 web/src/app/api/fn/misc/get-hello.ts create mode 100644 web/src/app/api/fn/platforms/create-platform.ts create mode 100644 web/src/app/api/fn/platforms/delete-platform.ts create mode 100644 web/src/app/api/fn/platforms/get-platform.ts create mode 100644 web/src/app/api/fn/platforms/list-platforms.ts create mode 100644 web/src/app/api/fn/platforms/update-platform.ts create mode 100644 web/src/app/api/functions.ts create mode 100644 web/src/app/api/models.ts create mode 100644 web/src/app/api/models/auth-response.ts create mode 100644 web/src/app/api/models/error-detail.ts create mode 100644 web/src/app/api/models/error-model.ts create mode 100644 web/src/app/api/models/game-input.ts create mode 100644 web/src/app/api/models/game.ts create mode 100644 web/src/app/api/models/get-hello-response.ts create mode 100644 web/src/app/api/models/login-input.ts create mode 100644 web/src/app/api/models/platform-input.ts create mode 100644 web/src/app/api/models/platform.ts create mode 100644 web/src/app/api/models/register-input.ts create mode 100644 web/src/app/api/models/user.ts create mode 100644 web/src/app/api/request-builder.ts create mode 100644 web/src/app/api/services.ts create mode 100644 web/src/app/api/services/auth.service.ts create mode 100644 web/src/app/api/services/games.service.ts create mode 100644 web/src/app/api/services/misc.service.ts create mode 100644 web/src/app/api/services/platforms.service.ts create mode 100644 web/src/app/api/strict-http-response.ts create mode 100644 web/src/app/app.config.ts create mode 100644 web/src/app/app.routes.ts create mode 100644 web/src/app/app.spec.ts create mode 100644 web/src/app/app.ts create mode 100644 web/src/app/core/auth-guard.spec.ts create mode 100644 web/src/app/core/auth-guard.ts create mode 100644 web/src/app/core/auth-interceptor.spec.ts create mode 100644 web/src/app/core/auth-interceptor.ts create mode 100644 web/src/app/core/session.spec.ts create mode 100644 web/src/app/core/session.ts create mode 100644 web/src/app/features/games/games.css create mode 100644 web/src/app/features/games/games.html create mode 100644 web/src/app/features/games/games.spec.ts create mode 100644 web/src/app/features/games/games.ts create mode 100644 web/src/app/features/login/login.css create mode 100644 web/src/app/features/login/login.html create mode 100644 web/src/app/features/login/login.spec.ts create mode 100644 web/src/app/features/login/login.ts create mode 100644 web/src/index.html create mode 100644 web/src/main.ts create mode 100644 web/src/material-theme.scss create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.spec.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d67ddf4 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [main, develop] + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # cmd/gui (Fyne) uses cgo and links against the system's OpenGL/X11 + # libraries, so building or testing ./... needs these present even + # though the GUI itself never opens a window in CI. + - name: Install system dependencies for the Fyne GUI build + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev xorg-dev + + - name: gofmt + run: | + fmt_out="$(gofmt -l .)" + if [ -n "$fmt_out" ]; then + echo "The following files are not gofmt'd:" + echo "$fmt_out" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go build + run: go build ./... + + - name: go test + run: go test ./... + + - name: go mod tidy (no diff expected) + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + sqlc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: "1.31.1" + + - name: sqlc diff (generated code must match the schema/queries) + run: sqlc diff + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..13136d8 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,168 @@ +name: Release + +# A pushed tag like v1.2.3 builds every client and attaches the result to a +# Gitea release of the same name. Tag a commit that has already passed CI +# on main; this workflow doesn't re-run those checks. +on: + push: + tags: ["v*.*.*"] + +jobs: + # cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly + # from one Linux runner for every OS/arch below. + build-go: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build api and tui + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + mkdir -p dist + ext="" + if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi + go build -o "dist/completed-api${ext}" ./cmd/api + go build -o "dist/completed-tui${ext}" ./cmd/tui + + - name: Archive + run: | + cd dist + base="completed_${{ matrix.goos }}_${{ matrix.goarch }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + zip "../${base}.zip" completed-api.exe completed-tui.exe + else + tar czf "../${base}.tar.gz" completed-api completed-tui + fi + + - uses: actions/upload-artifact@v4 + with: + name: completed_${{ matrix.goos }}_${{ matrix.goarch }} + path: completed_${{ matrix.goos }}_${{ matrix.goarch }}.* + retention-days: 7 + + # cmd/gui (Fyne) uses cgo and links the system's native GL/windowing + # libraries, so - unlike build-go above - it can't be cross-compiled for + # other operating systems from this one Linux runner. This job only + # produces a linux/amd64 build. A macOS or Windows build of the GUI would + # need this workflow extended with a job running on a matching + # self-hosted runner (there isn't one registered for this repo today). + build-gui-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev xorg-dev + + - name: Build gui + run: | + mkdir -p dist + go build -o dist/completed-gui ./cmd/gui + + - name: Archive + run: tar czf completed-gui_linux_amd64.tar.gz -C dist completed-gui + + - uses: actions/upload-artifact@v4 + with: + name: completed-gui_linux_amd64 + path: completed-gui_linux_amd64.tar.gz + retention-days: 7 + + build-web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Build + working-directory: web + run: | + npm ci + npm run build + + - name: Archive + run: tar czf completed-web_dist.tar.gz -C web/dist/web . + + - uses: actions/upload-artifact@v4 + with: + name: completed-web_dist + path: completed-web_dist.tar.gz + retention-days: 7 + + release: + needs: [build-go, build-gui-linux, build-web] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create the Gitea release and upload each artifact + env: + # Gitea Actions auto-provides this for the compatibility GITHUB_* + # context; it needs the repo's Settings > Actions permissions set + # to allow writing contents. If your instance doesn't populate it, + # replace with a secret holding a personal/repo access token that + # has repo write access instead. + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y jq + + release_json=$(curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases") + release_id=$(echo "$release_json" | jq -r '.id') + + if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then + echo "Failed to create release: $release_json" >&2 + exit 1 + fi + + for f in artifacts/*; do + name=$(basename "$f") + echo "Uploading $name" + curl -sf -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${f}" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${name}" + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 15c8c3d..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - go: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: go vet - run: go vet ./... - - - name: go test - run: go test ./... - - - name: go mod tidy (no diff expected) - run: | - go mod tidy - git diff --exit-code -- go.mod go.sum - - sqlc: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: sqlc-dev/setup-sqlc@v4 - with: - sqlc-version: "1.31.1" - - - name: sqlc diff (generated code must match the schema/queries) - run: sqlc diff diff --git a/Justfile b/Justfile index 60b56ef..a9677a6 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ build-api: # Run the API server locally run-api: - go run ./cmd/api/main.go + go run ./cmd/api # Start the API and Database using Docker Compose docker-up: @@ -42,17 +42,35 @@ build-tui: # Run the Terminal UI run-tui: - go run ./cmd/tui/main.go + go run ./cmd/tui # --- GUI --- -# Build the GUI (Raylib) +# Build the GUI (Fyne) build-gui: go build -o bin/gui ./cmd/gui # Run the GUI run-gui: - go run ./cmd/gui/main.go + go run ./cmd/gui + +# --- Web --- + +# Install web dependencies +web-install: + cd web && npm ci + +# Run the web app's dev server +web-dev: + cd web && npm start + +# Build the web app for production (output: web/dist/web) +web-build: + cd web && npm run build + +# Run the web app's tests +web-test: + cd web && npm test # --- Database --- diff --git a/README.md b/README.md index c61868b..9dbef52 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,12 @@ your score for it, and whether (and when) you finished it. ## Status -Each account has its own private collection. The API is usable; there's no -GUI yet beyond two read-only clients (see below). +Each account has its own private game list, but all accounts share one +platform list (only an admin can add/rename/delete a platform - the first +account ever registered becomes an admin automatically). The API is usable. +There are three small-base clients: a terminal UI, a desktop GUI (Fyne), +and a web app (Angular) — each can log in and show your games, with more +to come. ## Running it @@ -28,7 +32,8 @@ Browse `http://localhost:8080/docs` for the generated API docs. ## Using the API ```sh -# Register (also logs you in) +# Register (also logs you in; the first account ever registered becomes +# an admin, which is required to create platforms - see Status above) curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","password":"a long password","name":"You"}' # -> {"token": "...", "user": {...}} @@ -44,19 +49,47 @@ curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN" A browser-based client can skip the `Authorization` header: register/login also set an httpOnly `session` cookie. -## Other clients +## Clients -- `just run-tui` — a read-only terminal viewer of `/games` (no login yet) -- `just run-gui` — a read-only raylib desktop viewer of `/games` (no login - yet; planned to move to Fyne) +- `just run-tui` — a terminal UI (Bubble Tea). Log in, see your games in a + scrollable table, `r` to refresh. +- `just run-gui` — a desktop GUI (Fyne). Log in, see your games in a table, + refresh. +- `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`, + proxying nothing itself; it talks directly to the API at `localhost:8080` + (CORS is permissive enough for local dev as-is). Log in, see your games. + +All three point at `http://localhost:8080` by default. The TUI and GUI read +`COMPLETED_API_URL` to use a different API; the web app's URL is a build-time +constant in `web/src/app/app.config.ts`. + +Each client is a small base - listing is there, but adding/editing a game +or managing platforms isn't yet. ## Development ```sh -just test # go test ./... -just sqlc # regenerate internal/db after changing db/query.sql or a migration +just test # go test ./... +just sqlc # regenerate internal/db after changing db/query.sql or a migration +just openapi # regenerate api/openapi.json after changing a route +just web-test # web app unit tests (Vitest) +just web-build # production build of the web app ``` Schema changes go in `db/migrations` as a new `_.up.sql` / `.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly, so there's no separate schema file to keep in sync. + +The TUI and GUI share `internal/client`, a typed Go HTTP client for the API. +The web app's API client (`web/src/app/api/`) is generated from +`api/openapi.json` by `ng-openapi-gen` (`cd web && npx ng-openapi-gen`) and +shouldn't be hand-edited. + +## CI/CD + +`.gitea/workflows/ci.yml` builds and tests everything (Go + web) on every +push to `main` or `develop`. `.gitea/workflows/release.yml` builds all three +clients and attaches them to a Gitea release when a `vX.Y.Z` tag is pushed; +see that file's comments for its one real limitation (the desktop GUI is +only built for linux/amd64, since it needs cgo and this repo has no +registered macOS/Windows runner to cross-build it on). diff --git a/api/openapi.json b/api/openapi.json index 4a277ef..6d044c9 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"is_admin":{"type":"boolean"},"name":{"type":"string"}},"required":["id","email","name","is_admin"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} +{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"is_admin":{"type":"boolean"},"name":{"type":"string"}},"required":["id","email","name","is_admin"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account","tags":["Auth"]}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session","tags":["Auth"]}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user","tags":["Auth"]}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account","tags":["Auth"]}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games","tags":["Games"]},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game","tags":["Games"]}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game","tags":["Games"]},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game","tags":["Games"]},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game","tags":["Games"]}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms","tags":["Platforms"]},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform","tags":["Platforms"]}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform","tags":["Platforms"]},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform","tags":["Platforms"]},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform","tags":["Platforms"]}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello","tags":["Misc"]}}}} diff --git a/cmd/api/main.go b/cmd/api/main.go index 7209db3..9dce95a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -131,6 +131,15 @@ func main() { e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) + // The web client (e.g. the Angular dev server on localhost:4200) calls + // this API from a different origin. It authenticates with a bearer + // token, not cookies, so this doesn't need AllowCredentials - which + // means a wildcard origin is safe here. Tighten AllowOrigins to the + // real deployed origin(s) if this ever serves more than local dev. + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization}, + })) humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) diff --git a/cmd/gui/app.go b/cmd/gui/app.go new file mode 100644 index 0000000..9b879c0 --- /dev/null +++ b/cmd/gui/app.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" + + "completed/internal/client" + "completed/internal/model" +) + +// guiApp holds the window and the single client all screens share. It's a +// small base: log in, see your games in a table, refresh. Adding/editing +// games is for a later pass. +type guiApp struct { + client *client.Client + window fyne.Window +} + +func newGUIApp(c *client.Client, w fyne.Window) *guiApp { + return &guiApp{client: c, window: w} +} + +// showLogin builds and displays the login form. It's the window's initial +// content. +func (a *guiApp) showLogin() { + email := widget.NewEntry() + email.SetPlaceHolder("email") + password := widget.NewPasswordEntry() + password.SetPlaceHolder("password") + status := widget.NewLabel("") + + form := widget.NewForm( + widget.NewFormItem("Email", email), + widget.NewFormItem("Password", password), + ) + form.SubmitText = "Log in" + form.OnSubmit = func() { + if err := validateLogin(email.Text, password.Text); err != nil { + status.SetText(err.Error()) + return + } + status.SetText("Logging in...") + a.login(email.Text, password.Text, status) + } + + a.window.SetContent(container.NewVBox( + widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + form, + status, + )) +} + +// login runs the login request off the UI goroutine, then either shows the +// games screen or reports the error back on the login form - both via +// fyne.Do, since Fyne widgets may only be touched from the UI goroutine. +func (a *guiApp) login(email, password string, status *widget.Label) { + go func() { + _, err := a.client.Login(context.Background(), email, password) + if err != nil { + fyne.Do(func() { status.SetText(err.Error()) }) + return + } + fyne.Do(a.showGames) + }() +} + +// showGames displays the games table and fetches the current list. +func (a *guiApp) showGames() { + var games []model.Game + + table := widget.NewTable( + func() (int, int) { return gameTableSize(games) }, + func() fyne.CanvasObject { return widget.NewLabel("") }, + func(id widget.TableCellID, obj fyne.CanvasObject) { + obj.(*widget.Label).SetText(gameCellText(games, id.Row, id.Col)) + }, + ) + for i := range gameColumns { + table.SetColumnWidth(i, 120) + } + + status := widget.NewLabel("Loading...") + refresh := widget.NewButton("Refresh", func() { + a.fetchGames(table, &games, status) + }) + + a.window.SetContent(container.NewBorder( + container.NewVBox( + widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + container.NewHBox(refresh, status), + ), + nil, nil, nil, + table, + )) + + a.fetchGames(table, &games, status) +} + +func (a *guiApp) fetchGames(table *widget.Table, games *[]model.Game, status *widget.Label) { + go func() { + result, err := a.client.ListGames(context.Background()) + fyne.Do(func() { + if err != nil { + status.SetText("Error: " + err.Error()) + dialog.ShowError(err, a.window) + return + } + *games = result + status.SetText("") + table.Refresh() + }) + }() +} diff --git a/cmd/gui/main.go b/cmd/gui/main.go index 79d4853..7f100a2 100644 --- a/cmd/gui/main.go +++ b/cmd/gui/main.go @@ -1,145 +1,22 @@ package main import ( - "encoding/json" - "fmt" - "net/http" - "time" + "os" - rl "github.com/gen2brain/raylib-go/raylib" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + + "completed/internal/client" ) -type Game struct { - ID int64 `json:"id"` - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year"` - Finished string `json:"finished,omitempty"` -} - -type GamesResponse struct { - Body []Game `json:"body"` -} - func main() { - rl.InitWindow(800, 600, "My Game Collection - Raylib") - defer rl.CloseWindow() + fyneApp := app.New() + window := fyneApp.NewWindow("Completed") + window.Resize(fyne.NewSize(700, 500)) - rl.SetTargetFPS(60) + c := client.New(os.Getenv("COMPLETED_API_URL")) + a := newGUIApp(c, window) + a.showLogin() - loading := false - var err error - var games []Game - - // Channel to receive data from background goroutine - dataChan := make(chan []Game) - errChan := make(chan error) - - func() { - loading = true - go fetchGames(dataChan, errChan) - }() - - for !rl.WindowShouldClose() { - rl.BeginDrawing() - rl.ClearBackground(rl.RayWhite) - - // Header - rl.DrawRectangle(0, 0, 800, 60, rl.NewColor(100, 100, 255, 255)) - rl.DrawText("My Game Collection", 20, 15, 30, rl.White) - - startY := 80 - - if loading { - rl.DrawText("Loading games...", 20, int32(startY), 20, rl.Gray) - // Simple spinner animation - if int(rl.GetTime()*4)%2 == 0 { - rl.DrawText(".", 180, int32(startY), 20, rl.Gray) - } - } else if err != nil { - rl.DrawText(fmt.Sprintf("Error: %v", err), 20, int32(startY), 20, rl.Red) - rl.DrawText("Press SPACE to retry", 20, int32(startY)+30, 20, rl.DarkGray) - } else { - if len(games) == 0 { - rl.DrawText("No games found in database.", 20, int32(startY), 20, rl.DarkGray) - } else { - for i, g := range games { - yPos := int32(startY + (i * 40)) - - // Row background (alternating) - if i%2 == 0 { - rl.DrawRectangle(20, yPos-5, 760, 35, rl.NewColor(240, 240, 240, 255)) - } - - // Game Name - rl.DrawText(g.Name, 30, yPos, 20, rl.Black) - - // Year - rl.DrawText(g.ReleaseYear, 350, yPos, 20, rl.DarkGray) - - // Score - scoreColor := rl.Orange - if g.Score >= 90 { - scoreColor = rl.Green - } else if g.Score < 70 { - scoreColor = rl.Red - } - rl.DrawText(fmt.Sprintf("%d", g.Score), 500, yPos, 20, scoreColor) - - // Finished Status - if g.Finished != "" { - rl.DrawText("Finished", 600, yPos, 20, rl.NewColor(0, 200, 0, 255)) - } - } - } - } - - // Footer / Help - rl.DrawText("Press SPACE to refresh", 20, 570, 10, rl.Gray) - - rl.EndDrawing() - - // Inputs - if rl.IsKeyPressed(rl.KeySpace) && !loading { - loading = true - err = nil - games = nil - go fetchGames(dataChan, errChan) - } - - // Check channels (non-blocking) - select { - case data := <-dataChan: - loading = false - games = data - case e := <-errChan: - loading = false - err = e - default: - } - } -} - -func fetchGames(dataChan chan<- []Game, errChan chan<- error) { - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get("http://localhost:8080/games") - if err != nil { - errChan <- err - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - errChan <- fmt.Errorf("status code: %d", resp.StatusCode) - return - } - - var data GamesResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - errChan <- err - return - } - - dataChan <- data.Body + window.ShowAndRun() } diff --git a/cmd/gui/table.go b/cmd/gui/table.go new file mode 100644 index 0000000..ddeec6e --- /dev/null +++ b/cmd/gui/table.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + + "completed/internal/model" +) + +// gameColumns are the games table's column headers, in order. Kept next to +// gameCellText so the two can't drift apart. +var gameColumns = []string{"Name", "Platform", "Score", "Released", "Finished"} + +// gameTableSize returns the (rows, cols) Fyne's widget.Table needs from its +// Length function: one row per game, plus a header row. +func gameTableSize(games []model.Game) (rows, cols int) { + return len(games) + 1, len(gameColumns) +} + +// gameCellText returns the text for row/col in the games table. row 0 is +// the header; row i (i>0) is games[i-1]. It has no Fyne dependency, so it's +// tested directly without needing any widget or display. +func gameCellText(games []model.Game, row, col int) string { + if row == 0 { + return gameColumns[col] + } + g := games[row-1] + switch col { + case 0: + return g.Name + case 1: + return g.PlatformName + case 2: + return fmt.Sprintf("%d", g.Score) + case 3: + return g.ReleaseDate + case 4: + if g.Finished != "" { + return "✓" + } + return "" + default: + return "" + } +} diff --git a/cmd/gui/table_test.go b/cmd/gui/table_test.go new file mode 100644 index 0000000..61f6f77 --- /dev/null +++ b/cmd/gui/table_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" + + "completed/internal/model" +) + +func TestGameTableSize(t *testing.T) { + games := []model.Game{{Name: "Doom"}, {Name: "Half-Life"}} + + rows, cols := gameTableSize(games) + if rows != 3 { + t.Errorf("rows = %d, want 3 (header + 2 games)", rows) + } + if cols != len(gameColumns) { + t.Errorf("cols = %d, want %d", cols, len(gameColumns)) + } +} + +func TestGameTableSizeEmpty(t *testing.T) { + rows, _ := gameTableSize(nil) + if rows != 1 { + t.Errorf("rows = %d, want 1 (header only)", rows) + } +} + +func TestGameCellTextHeader(t *testing.T) { + for col, want := range gameColumns { + if got := gameCellText(nil, 0, col); got != want { + t.Errorf("gameCellText(nil, 0, %d) = %q, want %q", col, got, want) + } + } +} + +func TestGameCellTextDataRows(t *testing.T) { + games := []model.Game{ + {Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"}, + {Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"}, + } + + tests := []struct { + row, col int + want string + }{ + {1, 0, "Doom"}, + {1, 1, "PC"}, + {1, 2, "95"}, + {1, 3, "1993-12-10"}, + {1, 4, ""}, + {2, 0, "Half-Life"}, + {2, 4, "✓"}, + } + for _, tt := range tests { + if got := gameCellText(games, tt.row, tt.col); got != tt.want { + t.Errorf("gameCellText(games, %d, %d) = %q, want %q", tt.row, tt.col, got, tt.want) + } + } +} diff --git a/cmd/gui/validate.go b/cmd/gui/validate.go new file mode 100644 index 0000000..e7261a2 --- /dev/null +++ b/cmd/gui/validate.go @@ -0,0 +1,16 @@ +package main + +import "errors" + +// validateLogin checks the login form before it's sent to the server, so a +// blank field shows an immediate, specific error instead of an HTTP +// round trip followed by a generic one. +func validateLogin(email, password string) error { + if email == "" { + return errors.New("email is required") + } + if password == "" { + return errors.New("password is required") + } + return nil +} diff --git a/cmd/gui/validate_test.go b/cmd/gui/validate_test.go new file mode 100644 index 0000000..3906f75 --- /dev/null +++ b/cmd/gui/validate_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestValidateLogin(t *testing.T) { + tests := []struct { + name, email, password string + wantErr bool + }{ + {"valid", "a@example.com", "secret", false}, + {"empty email", "", "secret", true}, + {"empty password", "a@example.com", "", true}, + {"both empty", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateLogin(tt.email, tt.password) + if (err != nil) != tt.wantErr { + t.Errorf("validateLogin(%q, %q) error = %v, wantErr %v", tt.email, tt.password, err, tt.wantErr) + } + }) + } +} diff --git a/cmd/tui/main.go b/cmd/tui/main.go index d40ac05..937c6eb 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -1,150 +1,20 @@ package main import ( - "encoding/json" "fmt" - "net/http" "os" - "strings" - "time" - "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + + "completed/internal/client" ) -// Styles -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - Padding(0, 1) - - itemStyle = lipgloss.NewStyle(). - PaddingLeft(2) - - scoreStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD700")) // Gold color - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5555")). - Bold(true) -) - -type Game struct { - ID int64 `json:"id"` - Name string `json:"name"` - PlatformID int64 `json:"platform_id"` - Score int32 `json:"score"` - ReleaseYear string `json:"release_year"` - Finished string `json:"finished,omitempty"` -} - -type GamesResponse struct { - Body []Game `json:"body"` -} - -type Model struct { - spinner spinner.Model - loading bool - err error - games []Game - quitting bool -} - -func InitialModel() Model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - return Model{spinner: s, loading: true} -} - -type loadedMsg []Game -type errMsg error - -func fetchGames() tea.Msg { - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get("http://localhost:8080/games") - if err != nil { - return errMsg(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return errMsg(fmt.Errorf("status code: %d", resp.StatusCode)) - } - - var data GamesResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return errMsg(err) - } - - return loadedMsg(data.Body) -} - -func (m Model) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, fetchGames) -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "q" || msg.String() == "ctrl+c" { - m.quitting = true - return m, tea.Quit - } - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case loadedMsg: - m.loading = false - m.games = msg - return m, nil - case errMsg: - m.loading = false - m.err = msg - return m, nil - } - return m, nil -} - -func (m Model) View() string { - if m.quitting { - return "Bye!\n" - } - - s := titleStyle.Render("My Game Collection") + "\n\n" - - if m.loading { - s += fmt.Sprintf("%s Loading games...", m.spinner.View()) - } else if m.err != nil { - s += errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) - s += "\nMake sure the API is running on localhost:8080" - } else { - if len(m.games) == 0 { - s += "No games found." - } else { - for _, g := range m.games { - score := scoreStyle.Render(fmt.Sprintf("★ %d", g.Score)) - line := fmt.Sprintf("• %s (%s) - %s", g.Name, g.ReleaseYear, score) - if g.Finished != "" { - line += " [✓ Finished]" - } - s += itemStyle.Render(line) + "\n" - } - } - } - - s += strings.Repeat("\n", 2) + lipgloss.NewStyle().Foreground(lipgloss.Color("#555")).Render("Press 'q' to quit") - return s -} - func main() { - p := tea.NewProgram(InitialModel()) + c := client.New(os.Getenv("COMPLETED_API_URL")) + + p := tea.NewProgram(NewModel(c)) if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + fmt.Fprintf(os.Stderr, "Alas, there's been an error: %v\n", err) os.Exit(1) } } diff --git a/cmd/tui/model.go b/cmd/tui/model.go new file mode 100644 index 0000000..cda432b --- /dev/null +++ b/cmd/tui/model.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "completed/internal/client" + "completed/internal/model" +) + +// screen is which part of the app is currently showing. The model only +// ever does one thing at a time, so a single enum (rather than a flag per +// feature) keeps Update's branching simple. +type screen int + +const ( + screenLogin screen = iota + screenLoading + screenGames +) + +// focusField is which of the two login inputs has keyboard focus. +type focusField int + +const ( + focusEmail focusField = iota + focusPassword +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7D56F4")). + Padding(0, 1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF5555")). + Bold(true) + + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) + + focusedInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")) +) + +// Model is the TUI's whole state. It's a small base: log in, see your +// games in a scrollable table, refresh. Adding/editing games is for a +// later pass; the point here is a working, tested skeleton the rest of +// the TUI can grow from. +type Model struct { + client *client.Client + + screen screen + err error + + // Login screen. + emailInput textinput.Model + passwordInput textinput.Model + focus focusField + + // Loading screen (used for both the initial post-login fetch and a + // manual refresh). + spinner spinner.Model + + // Games screen. + table table.Model + user model.User +} + +// NewModel builds the initial Model, showing the login screen. +func NewModel(c *client.Client) Model { + email := textinput.New() + email.Placeholder = "email" + email.Focus() + email.CharLimit = 254 + + password := textinput.New() + password.Placeholder = "password" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 254 + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return Model{ + client: c, + screen: screenLogin, + emailInput: email, + passwordInput: password, + spinner: s, + table: newGamesTable(nil), + } +} + +func newGamesTable(games []model.Game) table.Model { + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Platform", Width: 16}, + {Title: "Score", Width: 7}, + {Title: "Released", Width: 12}, + {Title: "Finished", Width: 10}, + } + t := table.New( + table.WithColumns(columns), + table.WithRows(gamesToRows(games)), + table.WithFocused(true), + table.WithHeight(15), + ) + return t +} + +func gamesToRows(games []model.Game) []table.Row { + rows := make([]table.Row, len(games)) + for i, g := range games { + finished := "" + if g.Finished != "" { + finished = "✓" + } + rows[i] = table.Row{g.Name, g.PlatformName, fmt.Sprintf("%d", g.Score), g.ReleaseDate, finished} + } + return rows +} + +// --- Messages --- + +type loginSucceededMsg struct{ user model.User } +type loginFailedMsg struct{ err error } +type gamesLoadedMsg struct{ games []model.Game } +type gamesLoadFailedMsg struct{ err error } + +// --- Commands --- + +func doLogin(c *client.Client, email, password string) tea.Cmd { + return func() tea.Msg { + resp, err := c.Login(context.Background(), email, password) + if err != nil { + return loginFailedMsg{err: err} + } + return loginSucceededMsg{user: resp.User} + } +} + +func doListGames(c *client.Client) tea.Cmd { + return func() tea.Msg { + games, err := c.ListGames(context.Background()) + if err != nil { + return gamesLoadFailedMsg{err: err} + } + return gamesLoadedMsg{games: games} + } +} + +// --- bubbletea.Model --- + +func (m Model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, textinput.Blink) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.updateKey(msg) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case loginSucceededMsg: + m.user = msg.user + m.err = nil + m.screen = screenLoading + return m, doListGames(m.client) + + case loginFailedMsg: + m.err = msg.err + return m, nil + + case gamesLoadedMsg: + m.table = newGamesTable(msg.games) + m.err = nil + m.screen = screenGames + return m, nil + + case gamesLoadFailedMsg: + m.err = msg.err + m.screen = screenGames + return m, nil + } + return m, nil +} + +func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + switch m.screen { + case screenLogin: + return m.updateLoginKey(msg) + case screenGames: + if msg.String() == "q" { + return m, tea.Quit + } + if msg.String() == "r" { + m.screen = screenLoading + m.err = nil + return m, doListGames(m.client) + } + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "tab", "shift+tab", "down", "up": + if m.focus == focusEmail { + m.focus = focusPassword + m.emailInput.Blur() + m.passwordInput.Focus() + } else { + m.focus = focusEmail + m.passwordInput.Blur() + m.emailInput.Focus() + } + return m, nil + + case "enter": + m.err = nil + m.screen = screenLoading + return m, doLogin(m.client, m.emailInput.Value(), m.passwordInput.Value()) + } + + var cmd tea.Cmd + if m.focus == focusEmail { + m.emailInput, cmd = m.emailInput.Update(msg) + } else { + m.passwordInput, cmd = m.passwordInput.Update(msg) + } + return m, cmd +} + +func (m Model) View() string { + switch m.screen { + case screenLogin: + return m.viewLogin() + case screenLoading: + return fmt.Sprintf("%s Loading...\n", m.spinner.View()) + case screenGames: + return m.viewGames() + } + return "" +} + +func (m Model) viewLogin() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Completed") + "\n\n") + + emailLabel, passwordLabel := "Email", "Password" + if m.focus == focusEmail { + emailLabel = focusedInputStyle.Render(emailLabel) + } else { + passwordLabel = focusedInputStyle.Render(passwordLabel) + } + b.WriteString(emailLabel + "\n" + m.emailInput.View() + "\n\n") + b.WriteString(passwordLabel + "\n" + m.passwordInput.View() + "\n\n") + + if m.err != nil { + b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n") + } + + b.WriteString(helpStyle.Render("tab: switch field · enter: log in · ctrl+c: quit")) + return b.String() +} + +func (m Model) viewGames() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Completed") + " " + m.user.Name + "\n\n") + + if m.err != nil { + b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n") + } + + b.WriteString(m.table.View() + "\n\n") + b.WriteString(helpStyle.Render("r: refresh · q: quit")) + return b.String() +} diff --git a/cmd/tui/model_test.go b/cmd/tui/model_test.go new file mode 100644 index 0000000..d2c350b --- /dev/null +++ b/cmd/tui/model_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "completed/internal/client" + "completed/internal/model" +) + +// key builds the tea.KeyMsg Update expects for a named key, matching what +// bubbletea itself constructs from terminal input. +func key(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func update(t *testing.T, m Model, msg tea.Msg) Model { + t.Helper() + newModel, _ := m.Update(msg) + m, ok := newModel.(Model) + if !ok { + t.Fatalf("Update returned %T, want Model", newModel) + } + return m +} + +func TestTypingFillsFocusedField(t *testing.T) { + m := NewModel(client.New("")) + + m = update(t, m, key("a")) + m = update(t, m, key("b")) + if got := m.emailInput.Value(); got != "ab" { + t.Errorf("emailInput.Value() = %q, want %q", got, "ab") + } + if got := m.passwordInput.Value(); got != "" { + t.Errorf("passwordInput.Value() = %q, want empty while email is focused", got) + } + + m = update(t, m, key("tab")) + m = update(t, m, key("c")) + if got := m.passwordInput.Value(); got != "c" { + t.Errorf("passwordInput.Value() = %q, want %q after tab switches focus", got, "c") + } +} + +func TestEnterOnLoginStartsLoading(t *testing.T) { + m := NewModel(client.New("")) + m.emailInput.SetValue("a@example.com") + m.passwordInput.SetValue("secret") + + newModel, cmd := m.Update(key("enter")) + m = newModel.(Model) + + if m.screen != screenLoading { + t.Errorf("screen = %v, want screenLoading", m.screen) + } + if cmd == nil { + t.Error("expected a command to perform the login request, got nil") + } +} + +func TestLoginSucceededTransitionsToLoadingGames(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenLoading + + newModel, cmd := m.Update(loginSucceededMsg{user: model.User{Name: "Alice"}}) + m = newModel.(Model) + + if m.screen != screenLoading { + t.Errorf("screen = %v, want to stay screenLoading while games load", m.screen) + } + if m.user.Name != "Alice" { + t.Errorf("user.Name = %q, want Alice", m.user.Name) + } + if cmd == nil { + t.Error("expected a command to fetch games, got nil") + } +} + +func TestLoginFailedStaysOnLoginWithError(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenLoading + wantErr := errors.New("invalid email or password") + + m = update(t, m, loginFailedMsg{err: wantErr}) + + if m.err != wantErr { + t.Errorf("err = %v, want %v", m.err, wantErr) + } +} + +func TestGamesLoadedPopulatesTableAndSwitchesScreen(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenLoading + games := []model.Game{ + {ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"}, + {ID: "g2", Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"}, + } + + m = update(t, m, gamesLoadedMsg{games: games}) + + if m.screen != screenGames { + t.Errorf("screen = %v, want screenGames", m.screen) + } + rows := m.table.Rows() + if len(rows) != 2 { + t.Fatalf("table has %d rows, want 2", len(rows)) + } + if rows[0][0] != "Doom" || rows[0][4] != "" { + t.Errorf("row 0 = %v, want Doom unfinished", rows[0]) + } + if rows[1][0] != "Half-Life" || rows[1][4] != "✓" { + t.Errorf("row 1 = %v, want Half-Life finished", rows[1]) + } +} + +func TestGamesLoadFailedShowsErrorOnGamesScreen(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenLoading + wantErr := errors.New("network error") + + m = update(t, m, gamesLoadFailedMsg{err: wantErr}) + + if m.screen != screenGames { + t.Errorf("screen = %v, want screenGames (so 'r' can retry)", m.screen) + } + if m.err != wantErr { + t.Errorf("err = %v, want %v", m.err, wantErr) + } +} + +func TestRefreshOnGamesScreenReloads(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenGames + m.table = newGamesTable([]model.Game{{ID: "g1", Name: "Doom"}}) + + newModel, cmd := m.Update(key("r")) + m = newModel.(Model) + + if m.screen != screenLoading { + t.Errorf("screen = %v, want screenLoading", m.screen) + } + if cmd == nil { + t.Error("expected a command to refetch games, got nil") + } +} + +func TestQuitOnGamesScreen(t *testing.T) { + m := NewModel(client.New("")) + m.screen = screenGames + + _, cmd := m.Update(key("q")) + if cmd == nil { + t.Fatal("expected tea.Quit, got nil") + } +} + +func TestCtrlCQuitsFromAnyScreen(t *testing.T) { + for _, s := range []screen{screenLogin, screenLoading, screenGames} { + m := NewModel(client.New("")) + m.screen = s + + _, cmd := m.Update(key("ctrl+c")) + if cmd == nil { + t.Errorf("screen %v: expected tea.Quit on ctrl+c, got nil", s) + } + } +} diff --git a/go.mod b/go.mod index 069d649..a16da97 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module completed go 1.25.4 require ( + fyne.io/fyne/v2 v2.8.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/danielgtaylor/huma/v2 v2.34.1 - github.com/gen2brain/raylib-go/raylib v0.55.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 @@ -17,35 +17,60 @@ require ( ) require ( + fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/FyshOS/fancyfs v0.0.1 // indirect + github.com/anthonynsimon/bild v0.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/purego v0.7.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect + github.com/fyne-io/glfw-js v0.4.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect + github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect + github.com/go-text/render v0.2.1 // indirect + github.com/go-text/typesetting v0.3.4 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/rymdport/portal v0.4.2 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/image v0.24.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum index e7a55fa..709d757 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,23 @@ +fyne.io/fyne/v2 v2.8.1 h1:EztGuE2W3Qhd0cWVmU+h5rkzNezUD1To6UqsoLQYUIM= +fyne.io/fyne/v2 v2.8.1/go.mod h1:kpeuFrClm0fiAgJYr2soTfwKMT5rzNcSKzmgGjxvHOY= +fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo= +fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= +github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync= +github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -16,13 +30,16 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,24 +55,46 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= -github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= -github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo= +github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= +github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg= +github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -66,6 +105,10 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -84,8 +127,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -98,6 +141,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -107,11 +154,16 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= +github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -123,6 +175,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -137,6 +191,8 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/internal/api/auth_routes.go b/internal/api/auth_routes.go index 4e66c1c..7441b5d 100644 --- a/internal/api/auth_routes.go +++ b/internal/api/auth_routes.go @@ -65,6 +65,7 @@ func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (stri func registerAuthRoutes(api huma.API, queries db.Querier) { huma.Register(api, huma.Operation{ OperationID: "register", + Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/register", Summary: "Register a new account", @@ -100,6 +101,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { huma.Register(api, huma.Operation{ OperationID: "login", + Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/login", Summary: "Log in to an existing account", @@ -133,6 +135,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { logoutOp := huma.Operation{ OperationID: "logout", + Tags: []string{"Auth"}, Method: http.MethodPost, Path: "/api/auth/logout", Summary: "Log out of the current session", @@ -149,6 +152,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { meOp := huma.Operation{ OperationID: "get-me", + Tags: []string{"Auth"}, Method: http.MethodGet, Path: "/api/auth/me", Summary: "Get the current user", diff --git a/internal/api/routes.go b/internal/api/routes.go index 1773fbe..9d5b996 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -17,6 +17,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) { // Root/Hello Route huma.Register(api, huma.Operation{ OperationID: "get-hello", + Tags: []string{"Misc"}, Method: http.MethodGet, Path: "/hello", Summary: "Say Hello", @@ -52,6 +53,7 @@ func RegisterRoutes(api huma.API, queries db.Querier) { func registerPlatformRoutes(api huma.API, queries db.Querier) { listOp := huma.Operation{ OperationID: "list-platforms", + Tags: []string{"Platforms"}, Method: http.MethodGet, Path: "/api/platforms", Summary: "List Platforms", @@ -71,6 +73,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { getOp := huma.Operation{ OperationID: "get-platform", + Tags: []string{"Platforms"}, Method: http.MethodGet, Path: "/api/platforms/{id}", Summary: "Get Platform", @@ -88,6 +91,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { createOp := huma.Operation{ OperationID: "create-platform", + Tags: []string{"Platforms"}, Method: http.MethodPost, Path: "/api/platforms", Summary: "Create Platform", @@ -103,6 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { updateOp := huma.Operation{ OperationID: "update-platform", + Tags: []string{"Platforms"}, Method: http.MethodPut, Path: "/api/platforms/{id}", Summary: "Update Platform", @@ -124,6 +129,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { deleteOp := huma.Operation{ OperationID: "delete-platform", + Tags: []string{"Platforms"}, Method: http.MethodDelete, Path: "/api/platforms/{id}", Summary: "Delete Platform", @@ -185,6 +191,7 @@ func parseDate(s string) (pgtype.Date, error) { func registerGameRoutes(api huma.API, queries db.Querier) { listOp := huma.Operation{ OperationID: "list-games", + Tags: []string{"Games"}, Method: http.MethodGet, Path: "/api/games", Summary: "List Games", @@ -212,6 +219,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) { getOp := huma.Operation{ OperationID: "get-game", + Tags: []string{"Games"}, Method: http.MethodGet, Path: "/api/games/{id}", Summary: "Get Game", @@ -237,6 +245,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) { createOp := huma.Operation{ OperationID: "create-game", + Tags: []string{"Games"}, Method: http.MethodPost, Path: "/api/games", Summary: "Create Game", @@ -280,6 +289,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) { updateOp := huma.Operation{ OperationID: "update-game", + Tags: []string{"Games"}, Method: http.MethodPut, Path: "/api/games/{id}", Summary: "Update Game", @@ -327,6 +337,7 @@ func registerGameRoutes(api huma.API, queries db.Querier) { deleteOp := huma.Operation{ OperationID: "delete-game", + Tags: []string{"Games"}, Method: http.MethodDelete, Path: "/api/games/{id}", Summary: "Delete Game", diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..59c82b4 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,218 @@ +// Package client is a thin, typed HTTP client for the Completed API. It's +// the shared base for the TUI and the Fyne GUI: both hold a *Client and +// call its methods instead of each hand-rolling net/http calls, so request +// building, auth, and error handling live in one tested place. +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "completed/internal/model" +) + +// DefaultBaseURL is used when no other base URL is configured. It matches +// cmd/api's own default port. +const DefaultBaseURL = "http://localhost:8080" + +// Client talks to the Completed API. The zero value is not usable; create +// one with New. A Client is safe for concurrent use once Token is no +// longer being written concurrently with reads - in practice, callers set +// it only right after Login/Register, before starting concurrent calls. +type Client struct { + BaseURL string + Token string + + httpClient *http.Client +} + +// New returns a Client pointed at baseURL. An empty baseURL is replaced +// with DefaultBaseURL. +func New(baseURL string) *Client { + if baseURL == "" { + baseURL = DefaultBaseURL + } + return &Client{ + BaseURL: baseURL, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// APIError is returned for any non-2xx response. It carries the status +// code so callers can distinguish, say, a 401 (show "wrong password") from +// a 500 (show "something went wrong"). +type APIError struct { + Status int + Title string + Detail string +} + +func (e *APIError) Error() string { + if e.Detail != "" { + return e.Detail + } + if e.Title != "" { + return e.Title + } + return fmt.Sprintf("request failed with status %d", e.Status) +} + +// huma's error body shape; see internal/api's ErrorModel (from the huma +// package) in api/openapi.json. +type errorBody struct { + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` +} + +// do sends an HTTP request and decodes a JSON response into out (if out is +// non-nil). It attaches the bearer token, if set, and turns any non-2xx +// response into an *APIError. +func (c *Client) do(ctx context.Context, method, path string, body, out any) error { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 300 { + var eb errorBody + _ = json.Unmarshal(data, &eb) // best-effort; fall back to status alone + return &APIError{Status: resp.StatusCode, Title: eb.Title, Detail: eb.Detail} + } + + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + +// --- Auth --- + +func (c *Client) Register(ctx context.Context, email, password, name string) (model.AuthResponse, error) { + var resp model.AuthResponse + err := c.do(ctx, http.MethodPost, "/api/auth/register", model.RegisterInput{ + Email: email, Password: password, Name: name, + }, &resp) + if err == nil { + c.Token = resp.Token + } + return resp, err +} + +func (c *Client) Login(ctx context.Context, email, password string) (model.AuthResponse, error) { + var resp model.AuthResponse + err := c.do(ctx, http.MethodPost, "/api/auth/login", model.LoginInput{ + Email: email, Password: password, + }, &resp) + if err == nil { + c.Token = resp.Token + } + return resp, err +} + +func (c *Client) Logout(ctx context.Context) error { + err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil) + c.Token = "" + return err +} + +func (c *Client) Me(ctx context.Context) (model.User, error) { + var u model.User + err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u) + return u, err +} + +// --- Platforms --- + +func (c *Client) ListPlatforms(ctx context.Context) ([]model.Platform, error) { + var platforms []model.Platform + err := c.do(ctx, http.MethodGet, "/api/platforms", nil, &platforms) + return platforms, err +} + +func (c *Client) GetPlatform(ctx context.Context, id string) (model.Platform, error) { + var p model.Platform + err := c.do(ctx, http.MethodGet, "/api/platforms/"+id, nil, &p) + return p, err +} + +// CreatePlatform requires the current user to be an admin; see +// internal/api/middleware.go's addAdminAuth. +func (c *Client) CreatePlatform(ctx context.Context, name string) (model.Platform, error) { + var p model.Platform + err := c.do(ctx, http.MethodPost, "/api/platforms", model.PlatformInput{Name: name}, &p) + return p, err +} + +func (c *Client) UpdatePlatform(ctx context.Context, id, name string) (model.Platform, error) { + var p model.Platform + err := c.do(ctx, http.MethodPut, "/api/platforms/"+id, model.PlatformInput{Name: name}, &p) + return p, err +} + +func (c *Client) DeletePlatform(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/api/platforms/"+id, nil, nil) +} + +// --- Games --- + +func (c *Client) ListGames(ctx context.Context) ([]model.Game, error) { + var games []model.Game + err := c.do(ctx, http.MethodGet, "/api/games", nil, &games) + return games, err +} + +func (c *Client) GetGame(ctx context.Context, id string) (model.Game, error) { + var g model.Game + err := c.do(ctx, http.MethodGet, "/api/games/"+id, nil, &g) + return g, err +} + +func (c *Client) CreateGame(ctx context.Context, in model.GameInput) (model.Game, error) { + var g model.Game + err := c.do(ctx, http.MethodPost, "/api/games", in, &g) + return g, err +} + +func (c *Client) UpdateGame(ctx context.Context, id string, in model.GameInput) (model.Game, error) { + var g model.Game + err := c.do(ctx, http.MethodPut, "/api/games/"+id, in, &g) + return g, err +} + +func (c *Client) DeleteGame(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/api/games/"+id, nil, nil) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..d79ec73 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,186 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "completed/internal/model" +) + +func newTestServer(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return New(srv.URL), srv +} + +func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("encode response: %v", err) + } +} + +func TestLoginSuccessStoresToken(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/auth/login" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var in model.LoginInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatalf("decode body: %v", err) + } + if in.Email != "alice@example.com" || in.Password != "secret" { + t.Fatalf("unexpected credentials: %+v", in) + } + writeJSON(t, w, http.StatusOK, model.AuthResponse{ + Token: "tok-123", + User: model.User{ID: "u1", Email: in.Email, Name: "Alice"}, + }) + }) + + resp, err := c.Login(t.Context(), "alice@example.com", "secret") + if err != nil { + t.Fatalf("Login: %v", err) + } + if resp.Token != "tok-123" { + t.Errorf("Token = %q, want tok-123", resp.Token) + } + if c.Token != "tok-123" { + t.Errorf("Client.Token = %q, want tok-123 (Login must store it)", c.Token) + } +} + +func TestLoginFailureDoesNotStoreToken(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, map[string]any{ + "title": "Unauthorized", "status": 401, "detail": "invalid email or password", + }) + }) + c.Token = "" // sanity + + _, err := c.Login(t.Context(), "alice@example.com", "wrong") + if err == nil { + t.Fatal("expected an error, got nil") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != http.StatusUnauthorized { + t.Errorf("Status = %d, want 401", apiErr.Status) + } + if apiErr.Error() != "invalid email or password" { + t.Errorf("Error() = %q, want the detail message", apiErr.Error()) + } + if c.Token != "" { + t.Errorf("Token = %q, want empty after a failed login", c.Token) + } +} + +func TestListGamesSendsBearerToken(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tok-abc" { + t.Errorf("Authorization header = %q, want %q", got, "Bearer tok-abc") + } + writeJSON(t, w, http.StatusOK, []model.Game{ + {ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"}, + }) + }) + c.Token = "tok-abc" + + games, err := c.ListGames(t.Context()) + if err != nil { + t.Fatalf("ListGames: %v", err) + } + if len(games) != 1 || games[0].Name != "Doom" { + t.Errorf("games = %+v, want one game named Doom", games) + } +} + +func TestListGamesUnauthorized(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusUnauthorized, map[string]any{ + "title": "Unauthorized", "status": 401, "detail": "authentication required", + }) + }) + // No token set. + + _, err := c.ListGames(t.Context()) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != http.StatusUnauthorized { + t.Errorf("Status = %d, want 401", apiErr.Status) + } +} + +func TestCreateGameSendsBodyAndParsesResponse(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/games" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var in model.GameInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatalf("decode body: %v", err) + } + if in.Name != "Half-Life" || in.Score != 96 { + t.Fatalf("unexpected body: %+v", in) + } + writeJSON(t, w, http.StatusOK, model.Game{ + ID: "g2", Name: in.Name, PlatformID: in.PlatformID, Score: in.Score, ReleaseDate: in.ReleaseDate, + }) + }) + + g, err := c.CreateGame(t.Context(), model.GameInput{ + Name: "Half-Life", PlatformID: "p1", Score: 96, ReleaseDate: "1998-11-19", + }) + if err != nil { + t.Fatalf("CreateGame: %v", err) + } + if g.ID != "g2" { + t.Errorf("ID = %q, want g2", g.ID) + } +} + +func TestCreatePlatformForbidden(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusForbidden, map[string]any{ + "title": "Forbidden", "status": 403, "detail": "admin access required", + }) + }) + c.Token = "tok-non-admin" + + _, err := c.CreatePlatform(t.Context(), "PC") + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != http.StatusForbidden { + t.Errorf("Status = %d, want 403", apiErr.Status) + } +} + +func TestLogoutClearsTokenEvenOnError(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + c.Token = "tok-abc" + + _ = c.Logout(t.Context()) + if c.Token != "" { + t.Errorf("Token = %q, want empty after Logout regardless of the response", c.Token) + } +} + +func TestDefaultBaseURL(t *testing.T) { + c := New("") + if c.BaseURL != DefaultBaseURL { + t.Errorf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL) + } +} diff --git a/web/.editorconfig b/web/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/web/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/web/.vscode/launch.json b/web/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/web/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/web/.vscode/tasks.json b/web/.vscode/tasks.json new file mode 100644 index 0000000..244306f --- /dev/null +++ b/web/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + } + ] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..83c5fd5 --- /dev/null +++ b/web/README.md @@ -0,0 +1,59 @@ +# Web + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.2.1. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/web/angular.json b/web/angular.json new file mode 100644 index 0000000..20fa820 --- /dev/null +++ b/web/angular.json @@ -0,0 +1,71 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "web": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/material-theme.scss", "src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "web:build:production" + }, + "development": { + "buildTarget": "web:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/web/ng-openapi-gen.json b/web/ng-openapi-gen.json new file mode 100644 index 0000000..91643f7 --- /dev/null +++ b/web/ng-openapi-gen.json @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json", + "input": "../api/openapi.json", + "output": "src/app/api", + "ignoreUnusedModels": false, + "services": true +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..371b290 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,7131 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@angular/animations": "^22.2.1", + "@angular/cdk": "^22.2.1", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/forms": "^22.2.0", + "@angular/material": "^22.2.1", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.2.1", + "@angular/cli": "^22.2.1", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^30.0.0", + "ng-openapi-gen": "^1.1.0", + "prettier": "^3.8.1", + "typescript": "~6.0.2", + "vitest": "^5.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2202.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2202.1.tgz", + "integrity": "sha512-WGRAxFj+R9LC8HuK3DnDgxHo/XrgfklNQeNgjq/phTEVq5ZdWxc8kDoPA4uQz4vF+HY2eycAAtOPfMvj2TBEiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.2.1", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.1.tgz", + "integrity": "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.7", + "rxjs": "7.8.2", + "source-map": "0.8.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.1.tgz", + "integrity": "sha512-frHhFwu2TEfTZX5jfAYL2bztuG5mGggfJhiM9bo5/QtLAi49LtRbJxIktsu6vjJdujxeB2TKD7KqnXwNYgd41Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.2.1", + "jsonc-parser": "3.3.1", + "magic-string": "1.4.1", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/animations": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-22.2.1.tgz", + "integrity": "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw==", + "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.2.1" + } + }, + "node_modules/@angular/build": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.2.1.tgz", + "integrity": "sha512-VA8EkK5Y6sJbBFyn2x5jzd3KByh29DDpI7n6hucT0sK6YkMJEvsojPUWet9ifwF3OL+ZrGvxYlAHyZGKVlqw0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2202.1", + "@babel/core": "8.0.5", + "@inquirer/confirm": "6.3.2", + "@parcel/watcher": "2.6.0", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.5.4", + "browserslist": "^4.26.0", + "chokidar": "5.0.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.1.0", + "magic-string": "1.4.1", + "mrmime": "2.0.1", + "oxc-parser": "0.150.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.8", + "sass": "1.104.1", + "sass-embedded": "1.104.1", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.3.0", + "xxhash-wasm": "1.1.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.6" + }, + "peerDependencies": { + "@angular/compiler": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.2.1", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^22.0.0", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@angular/cdk": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.2.1.tgz", + "integrity": "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/forms": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cli": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.2.1.tgz", + "integrity": "sha512-tKMP68SZP+A9YCSRg0c+2e/+yWp7qiFrjCuQAomHK7owNaxHnLigAbra+8QA4xmWHoGaQk89NMmUPyA1BsBp8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2202.1", + "@angular-devkit/core": "22.2.1", + "@angular-devkit/schematics": "22.2.1", + "@inquirer/prompts": "8.7.2", + "@listr2/prompt-adapter-inquirer": "4.2.7", + "@modelcontextprotocol/server": "2.0.0", + "@schematics/angular": "22.2.1", + "jsonc-parser": "3.3.1", + "listr2": "11.1.0", + "npm-package-arg": "14.0.0", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.8.5", + "yargs": "18.1.0", + "zod": "4.6.5" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.2.1.tgz", + "integrity": "sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.2.1.tgz", + "integrity": "sha512-KRSz0c/T5eEiFa+F1l9OppAebJj9pT8kU3ArBMlxR3v/vzhpCfuQsFVQ4xEFC7dEm3zZIdv9ASKAP6ocNC4D/Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.2.1.tgz", + "integrity": "sha512-Nh8sCBl0JF6x+H7/1hemY76JeTRC66EfMULT4B3ryF9srRSLYc6X6RfPRtYvrIyjgr6h3jZgrNHlV76wiJaOcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "8.0.1", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.2.1", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/core": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.2.1.tgz", + "integrity": "sha512-a8WlYTRiaMPz1KvLE5xjeWrLyZUuQX8bh6Ak7zajUci9iWsTUQefschjSwP1WKlin3JhM3XjmsTEDyZvSLIfRg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.2.1.tgz", + "integrity": "sha512-xHM+PhrvYDJ+edPxh6PWhkE99tXWbsMcxdXCWF6QcyByX+bsW2rVlNZ7dumOsUle9MYa8Yj0lZFACUWbwFw+4w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.2.1", + "@angular/core": "22.2.1", + "@angular/platform-browser": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/material": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-22.2.1.tgz", + "integrity": "sha512-qw+/aJBV4lqqoJgv1VBWbMBVnE7431SX0Mew1j7PO+s1UcTushbG7CIAEo1hwmRq3kyo4Qqch8+uzFq4R631HQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "22.2.1", + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/forms": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.2.1.tgz", + "integrity": "sha512-KQ3oguemaSqyNP00yqEvPouY7NC4Lji+6S8yfjaWaUZjSth/GckpeP/Fo9HtMqd04y6MZ6lEb2hbQtTqFcSdEQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/animations": "22.2.1", + "@angular/common": "22.2.1", + "@angular/core": "22.2.1" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.1.tgz", + "integrity": "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.2.1", + "@angular/core": "22.2.1", + "@angular/platform-browser": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", + "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "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/@babel/code-frame": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.5.tgz", + "integrity": "sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-compilation-targets": "^8.0.5", + "@babel/helpers": "^8.0.5", + "@babel/parser": "^8.0.5", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz", + "integrity": "sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.6", + "@babel/types": "^8.0.6", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.6.tgz", + "integrity": "sha512-HTrtZ4+iA0+kzdbCgvdSk1U4pEl3BvpGnlUbualCGNbrVhhQvhgBzurTUmqpWyvyHbWYWaIfdx2mOlL9LJjsew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "flru": "1.0.2", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz", + "integrity": "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.5.tgz", + "integrity": "sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.6.tgz", + "integrity": "sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.6.tgz", + "integrity": "sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-globals": "^8.0.6", + "@babel/parser": "^8.0.6", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.6", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.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/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "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.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "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.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "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.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "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.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "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.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", + "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.5", + "@inquirer/confirm": "^6.3.2", + "@inquirer/editor": "^5.3.3", + "@inquirer/expand": "^5.1.5", + "@inquirer/input": "^5.1.6", + "@inquirer/number": "^4.2.3", + "@inquirer/password": "^5.2.2", + "@inquirer/rawlist": "^5.3.5", + "@inquirer/search": "^4.3.3", + "@inquirer/select": "^5.2.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.7.tgz", + "integrity": "sha512-k180vxSv2KqmnDxoato6pCqEJsov4U5C0EVmldZkAdNeVBxY48A/LM6TCNPGIzDK/iZSfsZP0Mw+gbWc5hXLyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "11.1.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", + "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", + "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", + "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", + "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", + "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", + "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", + "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.150.0.tgz", + "integrity": "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.150.0.tgz", + "integrity": "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.150.0.tgz", + "integrity": "sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.150.0.tgz", + "integrity": "sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.150.0.tgz", + "integrity": "sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.150.0.tgz", + "integrity": "sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.150.0.tgz", + "integrity": "sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.150.0.tgz", + "integrity": "sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.150.0.tgz", + "integrity": "sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.150.0.tgz", + "integrity": "sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.150.0.tgz", + "integrity": "sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.150.0.tgz", + "integrity": "sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.150.0.tgz", + "integrity": "sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.150.0.tgz", + "integrity": "sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.150.0.tgz", + "integrity": "sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.150.0.tgz", + "integrity": "sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.150.0.tgz", + "integrity": "sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.150.0.tgz", + "integrity": "sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.150.0.tgz", + "integrity": "sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@schematics/angular": { + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.2.1.tgz", + "integrity": "sha512-L4Nl8D43znCzXwze9PtbQhG1GKlhWq9ip8Hz+e3LHgG3WpT5Tlr7aVsyHDqb9Qetuy0Mpd4tXE15STtGdA897w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.2.1", + "@angular-devkit/schematics": "22.2.1", + "jsonc-parser": "3.3.1", + "oxc-parser": "0.150.0", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "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", + "peer": true + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz", + "integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^7.0.0", + "css-what": "^8.0.0", + "dom-serializer": "^3.0.0", + "domhandler": "^6.0.0", + "htmlparser2": "^12.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=20.19.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/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/colorjs.io": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.7.1.tgz", + "integrity": "sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/color" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "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/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "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", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eol": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/eol/-/eol-0.10.0.tgz", + "integrity": "sha512-+w3ktYrOphcIqC1XKmhQYvM+o2uxgQFiimL7B6JPZJlWVxf7Lno9e/JWLPIgbHo7DoZ+b7jsf/NzrUcNe6ZTZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ryanve" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "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-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", + "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "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/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/listr2": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-11.1.0.tgz", + "integrity": "sha512-xsuczF6mWkEo/E80QHaEokQzKmTy7m80KcLRnkUt9wrcePres2w7dbQi5zOsWtDUg8Dac4gPFyu4EJ/IvKwTOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^6.1.1", + "log-update": "^8.0.0", + "wrap-ansi": "^10.0.1" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/lmdb": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", + "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.6", + "@lmdb/lmdb-darwin-x64": "3.5.6", + "@lmdb/lmdb-linux-arm": "3.5.6", + "@lmdb/lmdb-linux-arm64": "3.5.6", + "@lmdb/lmdb-linux-x64": "3.5.6", + "@lmdb/lmdb-win32-arm64": "3.5.6", + "@lmdb/lmdb-win32-x64": "3.5.6" + } + }, + "node_modules/lmdb/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-8.0.0.tgz", + "integrity": "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.3.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "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/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ng-openapi-gen": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ng-openapi-gen/-/ng-openapi-gen-1.1.0.tgz", + "integrity": "sha512-18Nujl6hPuacDfDJlw+II++w9IRrG6aHP90GLzuZOv9yB03yyoC2Dgd8Z5sXqVDRvgQxeWPwdXpsGvxyqhJDfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^14.2.1", + "argparse": "^2.0.1", + "eol": "^0.10.0", + "fs-extra": "^11.4.1", + "handlebars": "^4.7.9", + "jsesc": "^3.1.0", + "lodash": "^4.18.1", + "openapi-types": "^12.1.3" + }, + "bin": { + "ng-openapi-gen": "lib/index.js" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "rxjs": ">=6.5.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-package-arg": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-14.0.0.tgz", + "integrity": "sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.2.tgz", + "integrity": "sha512-BY+qgDArmYpvvmij141xbkB4Cw8RFLBQM5Zj4MODFb1SyXBRE/xO4Xj/QWbR7aUEBxbkjuNdsFVCL2TmbYrlNg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oxc-parser": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.150.0.tgz", + "integrity": "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.150.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.150.0", + "@oxc-parser/binding-android-arm64": "0.150.0", + "@oxc-parser/binding-darwin-arm64": "0.150.0", + "@oxc-parser/binding-darwin-x64": "0.150.0", + "@oxc-parser/binding-freebsd-x64": "0.150.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", + "@oxc-parser/binding-linux-arm64-musl": "0.150.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-musl": "0.150.0", + "@oxc-parser/binding-openharmony-arm64": "0.150.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", + "@oxc-parser/binding-win32-x64-msvc": "0.150.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.3.2.tgz", + "integrity": "sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "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/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.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/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.1.tgz", + "integrity": "sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.104.1.tgz", + "integrity": "sha512-7LxbtPJ1MWKVbhzOnGx1sHTkHTcHVJ9hbgVsU29p/8gY1VeXt19fcJFr5xsx2f6zfrrR3FeOcYMt0uYDLAxpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "colorjs.io": "^0.7.0", + "immutable": "^5.1.5", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.104.1", + "sass-embedded-android-arm": "1.104.1", + "sass-embedded-android-arm64": "1.104.1", + "sass-embedded-android-riscv64": "1.104.1", + "sass-embedded-android-x64": "1.104.1", + "sass-embedded-darwin-arm64": "1.104.1", + "sass-embedded-darwin-x64": "1.104.1", + "sass-embedded-linux-arm": "1.104.1", + "sass-embedded-linux-arm64": "1.104.1", + "sass-embedded-linux-musl-arm": "1.104.1", + "sass-embedded-linux-musl-arm64": "1.104.1", + "sass-embedded-linux-musl-riscv64": "1.104.1", + "sass-embedded-linux-musl-x64": "1.104.1", + "sass-embedded-linux-riscv64": "1.104.1", + "sass-embedded-linux-x64": "1.104.1", + "sass-embedded-unknown-all": "1.104.1", + "sass-embedded-win32-arm64": "1.104.1", + "sass-embedded-win32-x64": "1.104.1" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.104.1.tgz", + "integrity": "sha512-toyMCyUKkT6DsYlHuKZGjd8T6WltaW4TGX07F7HnF4tNJM5m/8VaVsd74zrq3QSPqGE9JjxIDK4NrNc/QY9pkA==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.104.1" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.104.1.tgz", + "integrity": "sha512-g/XPAJYAzSSQZFP4xCdGD0HLKB4mf24cJ/+5Nls8rMool3jNkBv9+AFO4EV0b+RxN8rLcEsMccH1CR64yIFYcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.104.1.tgz", + "integrity": "sha512-yW3BVW4YFpN2vu2cVQ+Pz5JrRTvoIz36aCdo+J/Mo1A9gEN8eVPV/AL7aeZ803YdKgIitd3qw3pY4wpmT6TXNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.104.1.tgz", + "integrity": "sha512-ZYY8oMKQS7Q/6FBoC8w3T16O3MucDe3xv8iKym41Nht5uvLCGGpm4fC4OF5nlv7wQkJv8oactYddGG3Wd0Rqkg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.104.1.tgz", + "integrity": "sha512-5WZKC4lSfjMIjzZdQUlYQUAV+UiatBhD8w28U7cYLLj4/dMgEPOBd4g3VMv0BwpgH2JOaycMS42dXq29W2bcDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.104.1.tgz", + "integrity": "sha512-oatuHzn0NsEg0d9Dcze6CuWUPvuETtMxRK4cv0eRAg7p6R0vHnv5GZRyWG6gEy3nc/ha/L+GtTasWVU82f4jrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.104.1.tgz", + "integrity": "sha512-gql/5sHGzUTVwuHfM3QLCDeb6NYwen+LLj22gz81C2NKHF/4nUtDyh4N+fURWdT7J0/EhtMOd0+FLML3sGNP7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.104.1.tgz", + "integrity": "sha512-WfmshPJOp9vUKxgp8wZ2ncaKBbyrlW9J51M2aUQHWECjMNxTULnGOHWSmD7FViXv4zwn1Co5FIFQUipW7RCxtA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": "glibc", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.104.1.tgz", + "integrity": "sha512-sVWUKXNTu8sP3X95Df0xo/t49Ce5IseTIMqXHxhFBKVmx7gf7kBWNLK0wH5ym+4khc+B5nNJPgq4IxpelG8tMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": "glibc", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.104.1.tgz", + "integrity": "sha512-k0cQ/CxBzFVYiQEObg6CpF6CUglkdEhi/DZgZXQbwC27kbGGY+fatmIs/cFPJL2z1ulACwoAr86RQUFzYvw5eg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": "musl", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.104.1.tgz", + "integrity": "sha512-H7b+LzZ/w8pwNExInM8HDHraqvxCMdUc5ZRGU/aSJJhZ9HjeRKBjF3GxaisVHnx7o18j1l4VQ5cLBL9tTmNzZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": "musl", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.104.1.tgz", + "integrity": "sha512-9DYdn4wfrkidjxJVtEd2iCx8HKdZe+uF82GDmrYEZhgu4NjVE6a4fQyW7OLwzn3erZMHKBaObBOH1d37DnXI1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": "musl", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.104.1.tgz", + "integrity": "sha512-5xz+nAr01b6yaRdrGVgE7lNvm1lGFjwNdUvnz+teidvHCbD5oTcbhrRVrnDoLHHvq5n3Jve9TzWaaHhezaEfHA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": "musl", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.104.1.tgz", + "integrity": "sha512-oWryPJWTXK5adtlz/FD7wd2kS4I0CEgp6mWLUVb4oMFURMLz9bA9fO87D7lc4SV++lds7T51PU1kykCbOvNHMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": "glibc", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.104.1.tgz", + "integrity": "sha512-j/sX/yKTx/wJKepC5/ISlR6kPeSuHKThf76D2za5i2VnbHg0MN19jQq3WMorowR3kRghqt6azs3awogG+e7IRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": "glibc", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.104.1.tgz", + "integrity": "sha512-RaMsYZB5mRioL4qhoI7T5mmNb2OunakbPTJW2cuu3MeIULnGoxo7Wd5QKFxeQvF7sSQL57RuaQ+q9pH+/pQ9Fg==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.104.1" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.104.1.tgz", + "integrity": "sha512-fS6DL7lcnTPclqGLF4xRt8egZlFY9cU23d57ufbWqaUbCFu9JTu0+8qB4jMxfx1la6byi3GoSTfbnNOLfWv8sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.104.1.tgz", + "integrity": "sha512-/c4N2A93Ckms6DuRFVftKYISuI1tyC8gmlVJ8WaWFzgCBN/Swq2rARJIBUNeuxlmxjmYXfd3+2f/u887K63wbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "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/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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.1.tgz", + "integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.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/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", + "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "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/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz", + "integrity": "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3b09d1b --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "web", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "packageManager": "npm@11.19.1", + "dependencies": { + "@angular/animations": "^22.2.1", + "@angular/cdk": "^22.2.1", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/forms": "^22.2.0", + "@angular/material": "^22.2.1", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.2.1", + "@angular/cli": "^22.2.1", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^30.0.0", + "ng-openapi-gen": "^1.1.0", + "prettier": "^3.8.1", + "typescript": "~6.0.2", + "vitest": "^5.0.0" + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/web/src/app/api/api-configuration.ts b/web/src/app/api/api-configuration.ts new file mode 100644 index 0000000..6e2dc7b --- /dev/null +++ b/web/src/app/api/api-configuration.ts @@ -0,0 +1,27 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { Injectable } from '@angular/core'; + +/** + * Provides the ApiConfiguration configuration object with a given root URL. + */ +export function provideApiConfiguration(rootUrl: string) { + var config = new ApiConfiguration(); + config.rootUrl = rootUrl; + return { + provide: ApiConfiguration, + useValue: config + }; +} + +/** + * Global configuration + */ +@Injectable({ + providedIn: 'root', +}) +export class ApiConfiguration { + rootUrl: string = ''; +} + diff --git a/web/src/app/api/api.ts b/web/src/app/api/api.ts new file mode 100644 index 0000000..ece177f --- /dev/null +++ b/web/src/app/api/api.ts @@ -0,0 +1,64 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable, firstValueFrom } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { ApiConfiguration } from './api-configuration'; +import { StrictHttpResponse } from './strict-http-response'; + +export type ApiFnOptional = (http: HttpClient, rootUrl: string, params?: P, context?: HttpContext) => Observable>; +export type ApiFnRequired = (http: HttpClient, rootUrl: string, params: P, context?: HttpContext) => Observable>; + +/** + * Helper service to call API functions directly + */ +@Injectable({ providedIn: 'root' }) +export class Api { + constructor( + private config: ApiConfiguration, + private http: HttpClient + ) { + } + + private _rootUrl?: string; + + /** + * Returns the root url for API operations. If not set directly here, + * will fallback to `ApiConfiguration.rootUrl`. + */ + get rootUrl(): string { + return this._rootUrl || this.config.rootUrl; + } + + /** + * Sets the root URL for API operations + */ + set rootUrl(rootUrl: string) { + this._rootUrl = rootUrl; + } + + /** + * Executes an API call, returning the response body only + */ + invoke(fn: ApiFnRequired, params: P, context?: HttpContext): Promise; + invoke(fn: ApiFnOptional, params?: P, context?: HttpContext): Promise; + async invoke(fn: ApiFnRequired | ApiFnOptional, params: P, context?: HttpContext): Promise { + const resp = this.invoke$Response(fn, params, context); + return (await resp).body; + } + + /** + * Executes an API call, returning the entire response + */ + invoke$Response(fn: ApiFnRequired, params: P, context?: HttpContext): Promise>; + invoke$Response(fn: ApiFnOptional, params?: P, context?: HttpContext): Promise>; + invoke$Response(fn: ApiFnRequired | ApiFnOptional, params: P, context?: HttpContext): Promise> { + const obs = fn(this.http, this.rootUrl, params, context) + .pipe( + filter(r => r instanceof HttpResponse), + map(r => r as StrictHttpResponse)); + return firstValueFrom(obs); + } +} diff --git a/web/src/app/api/base-service.ts b/web/src/app/api/base-service.ts new file mode 100644 index 0000000..fcebae4 --- /dev/null +++ b/web/src/app/api/base-service.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { ApiConfiguration } from './api-configuration'; + +/** + * Base class for services + */ +@Injectable() +export class BaseService { + constructor( + protected config: ApiConfiguration, + protected http: HttpClient + ) { + } + + private _rootUrl?: string; + + /** + * Returns the root url for all operations in this service. If not set directly in this + * service, will fallback to `ApiConfiguration.rootUrl`. + */ + get rootUrl(): string { + return this._rootUrl || this.config.rootUrl; + } + + /** + * Sets the root URL for API operations in this service. + */ + set rootUrl(rootUrl: string) { + this._rootUrl = rootUrl; + } +} diff --git a/web/src/app/api/fn/auth/get-me.ts b/web/src/app/api/fn/auth/get-me.ts new file mode 100644 index 0000000..a620614 --- /dev/null +++ b/web/src/app/api/fn/auth/get-me.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { User } from '../../models/user'; + +export interface GetMe$Params { +} + +export function getMe(http: HttpClient, rootUrl: string, params?: GetMe$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, getMe.PATH, 'get'); + if (params) { + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +getMe.PATH = '/api/auth/me'; diff --git a/web/src/app/api/fn/auth/login.ts b/web/src/app/api/fn/auth/login.ts new file mode 100644 index 0000000..0537217 --- /dev/null +++ b/web/src/app/api/fn/auth/login.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { AuthResponse } from '../../models/auth-response'; +import { LoginInput } from '../../models/login-input'; + +export interface Login$Params { + body: LoginInput +} + +export function login(http: HttpClient, rootUrl: string, params: Login$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, login.PATH, 'post'); + if (params) { + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +login.PATH = '/api/auth/login'; diff --git a/web/src/app/api/fn/auth/logout.ts b/web/src/app/api/fn/auth/logout.ts new file mode 100644 index 0000000..9bee403 --- /dev/null +++ b/web/src/app/api/fn/auth/logout.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + + +export interface Logout$Params { +} + +export function logout(http: HttpClient, rootUrl: string, params?: Logout$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, logout.PATH, 'post'); + if (params) { + } + + return http.request( + rb.build({ responseType: 'text', accept: '*/*', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return (r as HttpResponse).clone({ body: undefined }) as StrictHttpResponse; + }) + ); +} + +logout.PATH = '/api/auth/logout'; diff --git a/web/src/app/api/fn/auth/register.ts b/web/src/app/api/fn/auth/register.ts new file mode 100644 index 0000000..c3cfb25 --- /dev/null +++ b/web/src/app/api/fn/auth/register.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { AuthResponse } from '../../models/auth-response'; +import { RegisterInput } from '../../models/register-input'; + +export interface Register$Params { + body: RegisterInput +} + +export function register(http: HttpClient, rootUrl: string, params: Register$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, register.PATH, 'post'); + if (params) { + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +register.PATH = '/api/auth/register'; diff --git a/web/src/app/api/fn/games/create-game.ts b/web/src/app/api/fn/games/create-game.ts new file mode 100644 index 0000000..0e59729 --- /dev/null +++ b/web/src/app/api/fn/games/create-game.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Game } from '../../models/game'; +import { GameInput } from '../../models/game-input'; + +export interface CreateGame$Params { + body: GameInput +} + +export function createGame(http: HttpClient, rootUrl: string, params: CreateGame$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, createGame.PATH, 'post'); + if (params) { + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +createGame.PATH = '/api/games'; diff --git a/web/src/app/api/fn/games/delete-game.ts b/web/src/app/api/fn/games/delete-game.ts new file mode 100644 index 0000000..dfe7b0b --- /dev/null +++ b/web/src/app/api/fn/games/delete-game.ts @@ -0,0 +1,31 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + + +export interface DeleteGame$Params { + id: string; +} + +export function deleteGame(http: HttpClient, rootUrl: string, params: DeleteGame$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, deleteGame.PATH, 'delete'); + if (params) { + rb.path('id', params.id, {}); + } + + return http.request( + rb.build({ responseType: 'text', accept: '*/*', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return (r as HttpResponse).clone({ body: undefined }) as StrictHttpResponse; + }) + ); +} + +deleteGame.PATH = '/api/games/{id}'; diff --git a/web/src/app/api/fn/games/get-game.ts b/web/src/app/api/fn/games/get-game.ts new file mode 100644 index 0000000..7d7227b --- /dev/null +++ b/web/src/app/api/fn/games/get-game.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Game } from '../../models/game'; + +export interface GetGame$Params { + id: string; +} + +export function getGame(http: HttpClient, rootUrl: string, params: GetGame$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, getGame.PATH, 'get'); + if (params) { + rb.path('id', params.id, {}); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +getGame.PATH = '/api/games/{id}'; diff --git a/web/src/app/api/fn/games/list-games.ts b/web/src/app/api/fn/games/list-games.ts new file mode 100644 index 0000000..3a0deea --- /dev/null +++ b/web/src/app/api/fn/games/list-games.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Game } from '../../models/game'; + +export interface ListGames$Params { +} + +export function listGames(http: HttpClient, rootUrl: string, params?: ListGames$Params, context?: HttpContext): Observable | null)>> { + const rb = new RequestBuilder(rootUrl, listGames.PATH, 'get'); + if (params) { + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse<(Array | null)>; + }) + ); +} + +listGames.PATH = '/api/games'; diff --git a/web/src/app/api/fn/games/update-game.ts b/web/src/app/api/fn/games/update-game.ts new file mode 100644 index 0000000..88f8f8e --- /dev/null +++ b/web/src/app/api/fn/games/update-game.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Game } from '../../models/game'; +import { GameInput } from '../../models/game-input'; + +export interface UpdateGame$Params { + id: string; + body: GameInput +} + +export function updateGame(http: HttpClient, rootUrl: string, params: UpdateGame$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, updateGame.PATH, 'put'); + if (params) { + rb.path('id', params.id, {}); + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +updateGame.PATH = '/api/games/{id}'; diff --git a/web/src/app/api/fn/misc/get-hello.ts b/web/src/app/api/fn/misc/get-hello.ts new file mode 100644 index 0000000..2808467 --- /dev/null +++ b/web/src/app/api/fn/misc/get-hello.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { GetHelloResponse } from '../../models/get-hello-response'; + +export interface GetHello$Params { +} + +export function getHello(http: HttpClient, rootUrl: string, params?: GetHello$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, getHello.PATH, 'get'); + if (params) { + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +getHello.PATH = '/hello'; diff --git a/web/src/app/api/fn/platforms/create-platform.ts b/web/src/app/api/fn/platforms/create-platform.ts new file mode 100644 index 0000000..33ef755 --- /dev/null +++ b/web/src/app/api/fn/platforms/create-platform.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Platform } from '../../models/platform'; +import { PlatformInput } from '../../models/platform-input'; + +export interface CreatePlatform$Params { + body: PlatformInput +} + +export function createPlatform(http: HttpClient, rootUrl: string, params: CreatePlatform$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, createPlatform.PATH, 'post'); + if (params) { + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +createPlatform.PATH = '/api/platforms'; diff --git a/web/src/app/api/fn/platforms/delete-platform.ts b/web/src/app/api/fn/platforms/delete-platform.ts new file mode 100644 index 0000000..fe922e8 --- /dev/null +++ b/web/src/app/api/fn/platforms/delete-platform.ts @@ -0,0 +1,31 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + + +export interface DeletePlatform$Params { + id: string; +} + +export function deletePlatform(http: HttpClient, rootUrl: string, params: DeletePlatform$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, deletePlatform.PATH, 'delete'); + if (params) { + rb.path('id', params.id, {}); + } + + return http.request( + rb.build({ responseType: 'text', accept: '*/*', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return (r as HttpResponse).clone({ body: undefined }) as StrictHttpResponse; + }) + ); +} + +deletePlatform.PATH = '/api/platforms/{id}'; diff --git a/web/src/app/api/fn/platforms/get-platform.ts b/web/src/app/api/fn/platforms/get-platform.ts new file mode 100644 index 0000000..a6cd9d5 --- /dev/null +++ b/web/src/app/api/fn/platforms/get-platform.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Platform } from '../../models/platform'; + +export interface GetPlatform$Params { + id: string; +} + +export function getPlatform(http: HttpClient, rootUrl: string, params: GetPlatform$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, getPlatform.PATH, 'get'); + if (params) { + rb.path('id', params.id, {}); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +getPlatform.PATH = '/api/platforms/{id}'; diff --git a/web/src/app/api/fn/platforms/list-platforms.ts b/web/src/app/api/fn/platforms/list-platforms.ts new file mode 100644 index 0000000..d4df6ec --- /dev/null +++ b/web/src/app/api/fn/platforms/list-platforms.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Platform } from '../../models/platform'; + +export interface ListPlatforms$Params { +} + +export function listPlatforms(http: HttpClient, rootUrl: string, params?: ListPlatforms$Params, context?: HttpContext): Observable | null)>> { + const rb = new RequestBuilder(rootUrl, listPlatforms.PATH, 'get'); + if (params) { + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse<(Array | null)>; + }) + ); +} + +listPlatforms.PATH = '/api/platforms'; diff --git a/web/src/app/api/fn/platforms/update-platform.ts b/web/src/app/api/fn/platforms/update-platform.ts new file mode 100644 index 0000000..fcb211a --- /dev/null +++ b/web/src/app/api/fn/platforms/update-platform.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { StrictHttpResponse } from '../../strict-http-response'; +import { RequestBuilder } from '../../request-builder'; + +import { Platform } from '../../models/platform'; +import { PlatformInput } from '../../models/platform-input'; + +export interface UpdatePlatform$Params { + id: string; + body: PlatformInput +} + +export function updatePlatform(http: HttpClient, rootUrl: string, params: UpdatePlatform$Params, context?: HttpContext): Observable> { + const rb = new RequestBuilder(rootUrl, updatePlatform.PATH, 'put'); + if (params) { + rb.path('id', params.id, {}); + rb.body(params.body, 'application/json'); + } + + return http.request( + rb.build({ responseType: 'json', accept: 'application/json', context }) + ).pipe( + filter((r: any): r is HttpResponse => r instanceof HttpResponse), + map((r: HttpResponse) => { + return r as StrictHttpResponse; + }) + ); +} + +updatePlatform.PATH = '/api/platforms/{id}'; diff --git a/web/src/app/api/functions.ts b/web/src/app/api/functions.ts new file mode 100644 index 0000000..98cf7e5 --- /dev/null +++ b/web/src/app/api/functions.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export type { Login$Params as Login$Params } from './fn/auth/login'; +export { login as login } from './fn/auth/login'; +export type { Logout$Params as Logout$Params } from './fn/auth/logout'; +export { logout as logout } from './fn/auth/logout'; +export type { GetMe$Params as GetMe$Params } from './fn/auth/get-me'; +export { getMe as getMe } from './fn/auth/get-me'; +export type { Register$Params as Register$Params } from './fn/auth/register'; +export { register as register } from './fn/auth/register'; +export type { ListGames$Params as ListGames$Params } from './fn/games/list-games'; +export { listGames as listGames } from './fn/games/list-games'; +export type { CreateGame$Params as CreateGame$Params } from './fn/games/create-game'; +export { createGame as createGame } from './fn/games/create-game'; +export type { GetGame$Params as GetGame$Params } from './fn/games/get-game'; +export { getGame as getGame } from './fn/games/get-game'; +export type { UpdateGame$Params as UpdateGame$Params } from './fn/games/update-game'; +export { updateGame as updateGame } from './fn/games/update-game'; +export type { DeleteGame$Params as DeleteGame$Params } from './fn/games/delete-game'; +export { deleteGame as deleteGame } from './fn/games/delete-game'; +export type { ListPlatforms$Params as ListPlatforms$Params } from './fn/platforms/list-platforms'; +export { listPlatforms as listPlatforms } from './fn/platforms/list-platforms'; +export type { CreatePlatform$Params as CreatePlatform$Params } from './fn/platforms/create-platform'; +export { createPlatform as createPlatform } from './fn/platforms/create-platform'; +export type { GetPlatform$Params as GetPlatform$Params } from './fn/platforms/get-platform'; +export { getPlatform as getPlatform } from './fn/platforms/get-platform'; +export type { UpdatePlatform$Params as UpdatePlatform$Params } from './fn/platforms/update-platform'; +export { updatePlatform as updatePlatform } from './fn/platforms/update-platform'; +export type { DeletePlatform$Params as DeletePlatform$Params } from './fn/platforms/delete-platform'; +export { deletePlatform as deletePlatform } from './fn/platforms/delete-platform'; +export type { GetHello$Params as GetHello$Params } from './fn/misc/get-hello'; +export { getHello as getHello } from './fn/misc/get-hello'; diff --git a/web/src/app/api/models.ts b/web/src/app/api/models.ts new file mode 100644 index 0000000..3f81675 --- /dev/null +++ b/web/src/app/api/models.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export type { AuthResponse } from './models/auth-response'; +export type { ErrorDetail } from './models/error-detail'; +export type { ErrorModel } from './models/error-model'; +export type { Game } from './models/game'; +export type { GameInput } from './models/game-input'; +export type { GetHelloResponse } from './models/get-hello-response'; +export type { LoginInput } from './models/login-input'; +export type { Platform } from './models/platform'; +export type { PlatformInput } from './models/platform-input'; +export type { RegisterInput } from './models/register-input'; +export type { User } from './models/user'; diff --git a/web/src/app/api/models/auth-response.ts b/web/src/app/api/models/auth-response.ts new file mode 100644 index 0000000..174a92a --- /dev/null +++ b/web/src/app/api/models/auth-response.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { User } from '../models/user'; +export interface AuthResponse { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + token: string; + user: User; +} diff --git a/web/src/app/api/models/error-detail.ts b/web/src/app/api/models/error-detail.ts new file mode 100644 index 0000000..f35752a --- /dev/null +++ b/web/src/app/api/models/error-detail.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface ErrorDetail { + + /** + * Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + */ + location?: string; + + /** + * Error message text + */ + message?: string; + + /** + * The value at the given location + */ + value?: any; +} diff --git a/web/src/app/api/models/error-model.ts b/web/src/app/api/models/error-model.ts new file mode 100644 index 0000000..6a329f9 --- /dev/null +++ b/web/src/app/api/models/error-model.ts @@ -0,0 +1,41 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { ErrorDetail } from '../models/error-detail'; +export interface ErrorModel { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + + /** + * A human-readable explanation specific to this occurrence of the problem. + */ + detail?: string; + + /** + * Optional list of individual error details + */ + errors?: (Array | null); + + /** + * A URI reference that identifies the specific occurrence of the problem. + */ + instance?: string; + + /** + * HTTP status code + */ + status?: number; + + /** + * A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + */ + title?: string; + + /** + * A URI reference to human-readable documentation for the error. + */ + type?: string; +} diff --git a/web/src/app/api/models/game-input.ts b/web/src/app/api/models/game-input.ts new file mode 100644 index 0000000..1b5824a --- /dev/null +++ b/web/src/app/api/models/game-input.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface GameInput { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + + /** + * YYYY-MM-DD (optional) + */ + finished?: string; + name: string; + platform_id: string; + + /** + * YYYY-MM-DD + */ + release_date: string; + score: number; +} diff --git a/web/src/app/api/models/game.ts b/web/src/app/api/models/game.ts new file mode 100644 index 0000000..34284fd --- /dev/null +++ b/web/src/app/api/models/game.ts @@ -0,0 +1,25 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface Game { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + + /** + * YYYY-MM-DD (optional) + */ + finished?: string; + id: string; + name: string; + platform_id: string; + platform_name: string; + + /** + * YYYY-MM-DD + */ + release_date: string; + score: number; +} diff --git a/web/src/app/api/models/get-hello-response.ts b/web/src/app/api/models/get-hello-response.ts new file mode 100644 index 0000000..5eece06 --- /dev/null +++ b/web/src/app/api/models/get-hello-response.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface GetHelloResponse { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + message: string; +} diff --git a/web/src/app/api/models/login-input.ts b/web/src/app/api/models/login-input.ts new file mode 100644 index 0000000..8694782 --- /dev/null +++ b/web/src/app/api/models/login-input.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface LoginInput { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + email: string; + password: string; +} diff --git a/web/src/app/api/models/platform-input.ts b/web/src/app/api/models/platform-input.ts new file mode 100644 index 0000000..3f2adbe --- /dev/null +++ b/web/src/app/api/models/platform-input.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface PlatformInput { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + + /** + * Name of the platform + */ + name: string; +} diff --git a/web/src/app/api/models/platform.ts b/web/src/app/api/models/platform.ts new file mode 100644 index 0000000..8e0e43d --- /dev/null +++ b/web/src/app/api/models/platform.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface Platform { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + id: string; + name: string; +} diff --git a/web/src/app/api/models/register-input.ts b/web/src/app/api/models/register-input.ts new file mode 100644 index 0000000..fe08469 --- /dev/null +++ b/web/src/app/api/models/register-input.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface RegisterInput { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + email: string; + name: string; + password: string; +} diff --git a/web/src/app/api/models/user.ts b/web/src/app/api/models/user.ts new file mode 100644 index 0000000..c974c31 --- /dev/null +++ b/web/src/app/api/models/user.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export interface User { + + /** + * A URL to the JSON Schema for this object. + */ + '$schema'?: string; + bio?: string; + email: string; + id: string; + is_admin: boolean; + name: string; +} diff --git a/web/src/app/api/request-builder.ts b/web/src/app/api/request-builder.ts new file mode 100644 index 0000000..9e28788 --- /dev/null +++ b/web/src/app/api/request-builder.ts @@ -0,0 +1,369 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpRequest, HttpParameterCodec, HttpParams, HttpHeaders, HttpContext } from '@angular/common/http'; + +/** + * Custom parameter codec to correctly handle the plus sign in parameter + * values. See https://github.com/angular/angular/issues/18261 + */ +class ParameterCodec implements HttpParameterCodec { + encodeKey(key: string): string { + return encodeURIComponent(key); + } + + encodeValue(value: string): string { + return encodeURIComponent(value); + } + + decodeKey(key: string): string { + return decodeURIComponent(key); + } + + decodeValue(value: string): string { + return decodeURIComponent(value); + } +} +const ParameterCodecInstance = new ParameterCodec(); + +/** + * Defines the options for appending a parameter + */ +interface ParameterOptions { + style?: string; + explode?: boolean; +} + +/** + * Base class for a parameter + */ +abstract class Parameter { + constructor(public name: string, public value: any, public options: ParameterOptions, defaultStyle: string, defaultExplode: boolean) { + this.options = options || {}; + if (this.options.style === null || this.options.style === undefined) { + this.options.style = defaultStyle; + } + if (this.options.explode === null || this.options.explode === undefined) { + this.options.explode = defaultExplode; + } + } + + serializeValue(value: any, separator = ','): string { + if (value === null || value === undefined) { + return ''; + } else if (value instanceof Array) { + return value.map(v => this.serializeValue(v).split(separator).join(encodeURIComponent(separator))).join(separator); + } else if (typeof value === 'object') { + const array: string[] = []; + for (const key of Object.keys(value)) { + let propVal = value[key]; + if (propVal !== null && propVal !== undefined) { + propVal = this.serializeValue(propVal).split(separator).join(encodeURIComponent(separator)); + if (this.options.explode) { + array.push(`${key}=${propVal}`); + } else { + array.push(key); + array.push(propVal); + } + } + } + return array.join(separator); + } else { + return String(value); + } + } +} + +/** + * A parameter in the operation path + */ +class PathParameter extends Parameter { + constructor(name: string, value: any, options: ParameterOptions) { + super(name, value, options, 'simple', false); + } + + append(path: string): string { + let value = this.value; + if (value === null || value === undefined) { + value = ''; + } + let prefix = this.options.style === 'label' ? '.' : ''; + let separator = this.options.explode ? prefix === '' ? ',' : prefix : ','; + let alreadySerialized = false; + if (this.options.style === 'matrix') { + // The parameter name is just used as prefix, except in some cases... + prefix = `;${this.name}=`; + if (this.options.explode && typeof value === 'object') { + prefix = ';'; + if (value instanceof Array) { + // For arrays we have to repeat the name for each element + value = value.map(v => `${this.name}=${this.serializeValue(v, ';')}`); + value = value.join(';'); + alreadySerialized = true; + } else { + // For objects we have to put each the key / value pairs + value = this.serializeValue(value, ';'); + alreadySerialized = true + } + } + } + value = prefix + (alreadySerialized ? value : this.serializeValue(value, separator)); + // Replace both the plain variable and the corresponding variant taking in the prefix and explode into account + path = path.replace(`{${this.name}}`, value); + path = path.replace(`{${prefix}${this.name}${this.options.explode ? '*' : ''}}`, value); + return path; + } + + // @ts-ignore + serializeValue(value: any, separator = ','): string { + var result = typeof value === 'string' ? encodeURIComponent(value) : super.serializeValue(value, separator); + result = result.replace(/%3D/g, '='); + result = result.replace(/%3B/g, ';'); + result = result.replace(/%2C/g, ','); + return result; + } +} + +/** + * A parameter in the query + */ +class QueryParameter extends Parameter { + constructor(name: string, value: any, options: ParameterOptions) { + super(name, value, options, 'form', true); + } + + append(params: HttpParams): HttpParams { + if (this.value instanceof Array) { + // Array serialization + if (this.options.explode) { + for (const v of this.value) { + params = params.append(this.name, this.serializeValue(v)); + } + } else { + const separator = this.options.style === 'spaceDelimited' + ? ' ' : this.options.style === 'pipeDelimited' + ? '|' : ','; + return params.append(this.name, this.serializeValue(this.value, separator)); + } + } else if (this.value !== null && typeof this.value === 'object') { + // Object serialization + if (this.options.style === 'deepObject') { + // Append a parameter for each key, in the form `name[key]` + for (const key of Object.keys(this.value)) { + const propVal = this.value[key]; + if (propVal !== null && propVal !== undefined) { + params = params.append(`${this.name}[${key}]`, this.serializeValue(propVal)); + } + } + } else if (this.options.explode) { + // Append a parameter for each key without using the parameter name + for (const key of Object.keys(this.value)) { + const propVal = this.value[key]; + if (propVal !== null && propVal !== undefined) { + params = params.append(key, this.serializeValue(propVal)); + } + } + } else { + // Append a single parameter whose values are a comma-separated list of key,value,key,value... + const array: any[] = []; + for (const key of Object.keys(this.value)) { + const propVal = this.value[key]; + if (propVal !== null && propVal !== undefined) { + array.push(key); + array.push(propVal); + } + } + params = params.append(this.name, this.serializeValue(array)); + } + } else if (this.value !== null && this.value !== undefined) { + // Plain value + params = params.append(this.name, this.serializeValue(this.value)); + } + return params; + } +} + +/** + * A parameter in the HTTP request header + */ +class HeaderParameter extends Parameter { + constructor(name: string, value: any, options: ParameterOptions) { + super(name, value, options, 'simple', false); + } + + append(headers: HttpHeaders): HttpHeaders { + if (this.value !== null && this.value !== undefined) { + if (this.value instanceof Array) { + for (const v of this.value) { + headers = headers.append(this.name, this.serializeValue(v)); + } + } else { + headers = headers.append(this.name, this.serializeValue(this.value)); + } + } + return headers; + } +} + +/** + * Helper to build http requests from parameters + */ +export class RequestBuilder { + + private _path = new Map(); + private _query = new Map(); + private _header = new Map(); + _bodyContent: any | null; + _bodyContentType?: string; + + constructor( + public rootUrl: string, + public operationPath: string, + public method: string) { + } + + /** + * Sets a path parameter + */ + path(name: string, value: any, options?: ParameterOptions): void { + this._path.set(name, new PathParameter(name, value, options || {})); + } + + /** + * Sets a query parameter + */ + query(name: string, value: any, options?: ParameterOptions): void { + this._query.set(name, new QueryParameter(name, value, options || {})); + } + + /** + * Sets a header parameter + */ + header(name: string, value: any, options?: ParameterOptions): void { + this._header.set(name, new HeaderParameter(name, value, options || {})); + } + + /** + * Sets the body content, along with the content type + */ + body(value: any, contentType = 'application/json'): void { + if (value instanceof Blob) { + this._bodyContentType = value.type; + } else { + this._bodyContentType = contentType; + } + if (this._bodyContentType === 'application/x-www-form-urlencoded' && value !== null && typeof value === 'object') { + // Handle URL-encoded data + const pairs: Array<[string, string]> = []; + for (const key of Object.keys(value)) { + let val = value[key]; + if (!(val instanceof Array)) { + val = [val]; + } + for (const v of val) { + const formValue = this.formDataValue(v); + if (formValue !== null) { + pairs.push([key, formValue]); + } + } + } + this._bodyContent = pairs.map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&'); + } else if (this._bodyContentType === 'multipart/form-data') { + // Handle multipart form data + const formData = new FormData(); + if (value !== null && value !== undefined) { + for (const key of Object.keys(value)) { + const val = value[key]; + if (val instanceof Array) { + for (const v of val) { + const toAppend = this.formDataValue(v); + if (toAppend !== null) { + formData.append(key, toAppend); + } + } + } else { + const toAppend = this.formDataValue(val); + if (toAppend !== null) { + formData.set(key, toAppend); + } + } + } + } + this._bodyContent = formData; + } else { + // The body is the plain content + this._bodyContent = value; + } + } + + private formDataValue(value: any): any { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Blob) { + return value; + } + if (typeof value === 'object') { + return new Blob([JSON.stringify(value)], {type: 'application/json'}) + } + return String(value); + } + + /** + * Builds the request with the current set parameters + */ + build(options?: { + /** Which content types to accept */ + accept?: string; + + /** The expected response type */ + responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; + + /** Whether to report progress on uploads / downloads */ + reportProgress?: boolean; + + /** Allow passing HttpContext for HttpClient */ + context?: HttpContext; + }): HttpRequest { + + options = options || {}; + + // Path parameters + let path = this.operationPath; + for (const pathParam of this._path.values()) { + path = pathParam.append(path); + } + const url = this.rootUrl + path; + + // Query parameters + let httpParams = new HttpParams({ + encoder: ParameterCodecInstance + }); + for (const queryParam of this._query.values()) { + httpParams = queryParam.append(httpParams); + } + + // Header parameters + let httpHeaders = new HttpHeaders(); + if (options.accept) { + httpHeaders = httpHeaders.append('Accept', options.accept); + } + for (const headerParam of this._header.values()) { + httpHeaders = headerParam.append(httpHeaders); + } + + // Request content headers + if (this._bodyContentType && !(this._bodyContent instanceof FormData)) { + httpHeaders = httpHeaders.set('Content-Type', this._bodyContentType); + } + + // Perform the request + return new HttpRequest(this.method.toUpperCase(), url, this._bodyContent, { + params: httpParams, + headers: httpHeaders, + responseType: options.responseType, + reportProgress: options.reportProgress, + context: options.context + }); + } +} diff --git a/web/src/app/api/services.ts b/web/src/app/api/services.ts new file mode 100644 index 0000000..c40f9fc --- /dev/null +++ b/web/src/app/api/services.ts @@ -0,0 +1,7 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +export { AuthService } from './services/auth.service'; +export { GamesService } from './services/games.service'; +export { PlatformsService } from './services/platforms.service'; +export { MiscService } from './services/misc.service'; diff --git a/web/src/app/api/services/auth.service.ts b/web/src/app/api/services/auth.service.ts new file mode 100644 index 0000000..5cc8618 --- /dev/null +++ b/web/src/app/api/services/auth.service.ts @@ -0,0 +1,161 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import { BaseService } from '../base-service'; +import { ApiConfiguration } from '../api-configuration'; +import { StrictHttpResponse } from '../strict-http-response'; + +import { AuthResponse } from '../models/auth-response'; +import { getMe } from '../fn/auth/get-me'; +import { GetMe$Params } from '../fn/auth/get-me'; +import { login } from '../fn/auth/login'; +import { Login$Params } from '../fn/auth/login'; +import { logout } from '../fn/auth/logout'; +import { Logout$Params } from '../fn/auth/logout'; +import { register } from '../fn/auth/register'; +import { Register$Params } from '../fn/auth/register'; +import { User } from '../models/user'; + +@Injectable({ providedIn: 'root' }) +export class AuthService extends BaseService { + constructor(config: ApiConfiguration, http: HttpClient) { + super(config, http); + } + + /** Path part for operation `login()` */ + static readonly LoginPath = '/api/auth/login'; + + /** + * Log in to an existing account. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `login()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + login$Response(params: Login$Params, context?: HttpContext): Promise> { + const obs = login(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Log in to an existing account. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `login$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + login(params: Login$Params, context?: HttpContext): Promise { + const resp = this.login$Response(params, context); + return resp.then((r: StrictHttpResponse): AuthResponse => r.body); + } + + /** Path part for operation `logout()` */ + static readonly LogoutPath = '/api/auth/logout'; + + /** + * Log out of the current session. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `logout()` instead. + * + * This method doesn't expect any request body. + */ + logout$Response(params?: Logout$Params, context?: HttpContext): Promise> { + const obs = logout(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Log out of the current session. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `logout$Response()` instead. + * + * This method doesn't expect any request body. + */ + logout(params?: Logout$Params, context?: HttpContext): Promise { + const resp = this.logout$Response(params, context); + return resp.then((r: StrictHttpResponse): void => r.body); + } + + /** Path part for operation `getMe()` */ + static readonly GetMePath = '/api/auth/me'; + + /** + * Get the current user. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `getMe()` instead. + * + * This method doesn't expect any request body. + */ + getMe$Response(params?: GetMe$Params, context?: HttpContext): Promise> { + const obs = getMe(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Get the current user. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `getMe$Response()` instead. + * + * This method doesn't expect any request body. + */ + getMe(params?: GetMe$Params, context?: HttpContext): Promise { + const resp = this.getMe$Response(params, context); + return resp.then((r: StrictHttpResponse): User => r.body); + } + + /** Path part for operation `register()` */ + static readonly RegisterPath = '/api/auth/register'; + + /** + * Register a new account. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `register()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + register$Response(params: Register$Params, context?: HttpContext): Promise> { + const obs = register(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Register a new account. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `register$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + register(params: Register$Params, context?: HttpContext): Promise { + const resp = this.register$Response(params, context); + return resp.then((r: StrictHttpResponse): AuthResponse => r.body); + } + +} diff --git a/web/src/app/api/services/games.service.ts b/web/src/app/api/services/games.service.ts new file mode 100644 index 0000000..4d412d2 --- /dev/null +++ b/web/src/app/api/services/games.service.ts @@ -0,0 +1,195 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import { BaseService } from '../base-service'; +import { ApiConfiguration } from '../api-configuration'; +import { StrictHttpResponse } from '../strict-http-response'; + +import { createGame } from '../fn/games/create-game'; +import { CreateGame$Params } from '../fn/games/create-game'; +import { deleteGame } from '../fn/games/delete-game'; +import { DeleteGame$Params } from '../fn/games/delete-game'; +import { Game } from '../models/game'; +import { getGame } from '../fn/games/get-game'; +import { GetGame$Params } from '../fn/games/get-game'; +import { listGames } from '../fn/games/list-games'; +import { ListGames$Params } from '../fn/games/list-games'; +import { updateGame } from '../fn/games/update-game'; +import { UpdateGame$Params } from '../fn/games/update-game'; + +@Injectable({ providedIn: 'root' }) +export class GamesService extends BaseService { + constructor(config: ApiConfiguration, http: HttpClient) { + super(config, http); + } + + /** Path part for operation `listGames()` */ + static readonly ListGamesPath = '/api/games'; + + /** + * List Games. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `listGames()` instead. + * + * This method doesn't expect any request body. + */ + listGames$Response(params?: ListGames$Params, context?: HttpContext): Promise | null)>> { + const obs = listGames(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * List Games. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `listGames$Response()` instead. + * + * This method doesn't expect any request body. + */ + listGames(params?: ListGames$Params, context?: HttpContext): Promise<(Array | null)> { + const resp = this.listGames$Response(params, context); + return resp.then((r: StrictHttpResponse<(Array | null)>): (Array | null) => r.body); + } + + /** Path part for operation `createGame()` */ + static readonly CreateGamePath = '/api/games'; + + /** + * Create Game. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `createGame()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + createGame$Response(params: CreateGame$Params, context?: HttpContext): Promise> { + const obs = createGame(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Create Game. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `createGame$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + createGame(params: CreateGame$Params, context?: HttpContext): Promise { + const resp = this.createGame$Response(params, context); + return resp.then((r: StrictHttpResponse): Game => r.body); + } + + /** Path part for operation `getGame()` */ + static readonly GetGamePath = '/api/games/{id}'; + + /** + * Get Game. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `getGame()` instead. + * + * This method doesn't expect any request body. + */ + getGame$Response(params: GetGame$Params, context?: HttpContext): Promise> { + const obs = getGame(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Get Game. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `getGame$Response()` instead. + * + * This method doesn't expect any request body. + */ + getGame(params: GetGame$Params, context?: HttpContext): Promise { + const resp = this.getGame$Response(params, context); + return resp.then((r: StrictHttpResponse): Game => r.body); + } + + /** Path part for operation `updateGame()` */ + static readonly UpdateGamePath = '/api/games/{id}'; + + /** + * Update Game. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `updateGame()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + updateGame$Response(params: UpdateGame$Params, context?: HttpContext): Promise> { + const obs = updateGame(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Update Game. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `updateGame$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + updateGame(params: UpdateGame$Params, context?: HttpContext): Promise { + const resp = this.updateGame$Response(params, context); + return resp.then((r: StrictHttpResponse): Game => r.body); + } + + /** Path part for operation `deleteGame()` */ + static readonly DeleteGamePath = '/api/games/{id}'; + + /** + * Delete Game. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `deleteGame()` instead. + * + * This method doesn't expect any request body. + */ + deleteGame$Response(params: DeleteGame$Params, context?: HttpContext): Promise> { + const obs = deleteGame(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Delete Game. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `deleteGame$Response()` instead. + * + * This method doesn't expect any request body. + */ + deleteGame(params: DeleteGame$Params, context?: HttpContext): Promise { + const resp = this.deleteGame$Response(params, context); + return resp.then((r: StrictHttpResponse): void => r.body); + } + +} diff --git a/web/src/app/api/services/misc.service.ts b/web/src/app/api/services/misc.service.ts new file mode 100644 index 0000000..71c69be --- /dev/null +++ b/web/src/app/api/services/misc.service.ts @@ -0,0 +1,55 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import { BaseService } from '../base-service'; +import { ApiConfiguration } from '../api-configuration'; +import { StrictHttpResponse } from '../strict-http-response'; + +import { GetHelloResponse } from '../models/get-hello-response'; +import { getHello } from '../fn/misc/get-hello'; +import { GetHello$Params } from '../fn/misc/get-hello'; + +@Injectable({ providedIn: 'root' }) +export class MiscService extends BaseService { + constructor(config: ApiConfiguration, http: HttpClient) { + super(config, http); + } + + /** Path part for operation `getHello()` */ + static readonly GetHelloPath = '/hello'; + + /** + * Say Hello. + * + * Returns a friendly hello message. + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `getHello()` instead. + * + * This method doesn't expect any request body. + */ + getHello$Response(params?: GetHello$Params, context?: HttpContext): Promise> { + const obs = getHello(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Say Hello. + * + * Returns a friendly hello message. + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `getHello$Response()` instead. + * + * This method doesn't expect any request body. + */ + getHello(params?: GetHello$Params, context?: HttpContext): Promise { + const resp = this.getHello$Response(params, context); + return resp.then((r: StrictHttpResponse): GetHelloResponse => r.body); + } + +} diff --git a/web/src/app/api/services/platforms.service.ts b/web/src/app/api/services/platforms.service.ts new file mode 100644 index 0000000..691f7b5 --- /dev/null +++ b/web/src/app/api/services/platforms.service.ts @@ -0,0 +1,195 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import { BaseService } from '../base-service'; +import { ApiConfiguration } from '../api-configuration'; +import { StrictHttpResponse } from '../strict-http-response'; + +import { createPlatform } from '../fn/platforms/create-platform'; +import { CreatePlatform$Params } from '../fn/platforms/create-platform'; +import { deletePlatform } from '../fn/platforms/delete-platform'; +import { DeletePlatform$Params } from '../fn/platforms/delete-platform'; +import { getPlatform } from '../fn/platforms/get-platform'; +import { GetPlatform$Params } from '../fn/platforms/get-platform'; +import { listPlatforms } from '../fn/platforms/list-platforms'; +import { ListPlatforms$Params } from '../fn/platforms/list-platforms'; +import { Platform } from '../models/platform'; +import { updatePlatform } from '../fn/platforms/update-platform'; +import { UpdatePlatform$Params } from '../fn/platforms/update-platform'; + +@Injectable({ providedIn: 'root' }) +export class PlatformsService extends BaseService { + constructor(config: ApiConfiguration, http: HttpClient) { + super(config, http); + } + + /** Path part for operation `listPlatforms()` */ + static readonly ListPlatformsPath = '/api/platforms'; + + /** + * List Platforms. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `listPlatforms()` instead. + * + * This method doesn't expect any request body. + */ + listPlatforms$Response(params?: ListPlatforms$Params, context?: HttpContext): Promise | null)>> { + const obs = listPlatforms(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * List Platforms. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `listPlatforms$Response()` instead. + * + * This method doesn't expect any request body. + */ + listPlatforms(params?: ListPlatforms$Params, context?: HttpContext): Promise<(Array | null)> { + const resp = this.listPlatforms$Response(params, context); + return resp.then((r: StrictHttpResponse<(Array | null)>): (Array | null) => r.body); + } + + /** Path part for operation `createPlatform()` */ + static readonly CreatePlatformPath = '/api/platforms'; + + /** + * Create Platform. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `createPlatform()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + createPlatform$Response(params: CreatePlatform$Params, context?: HttpContext): Promise> { + const obs = createPlatform(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Create Platform. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `createPlatform$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + createPlatform(params: CreatePlatform$Params, context?: HttpContext): Promise { + const resp = this.createPlatform$Response(params, context); + return resp.then((r: StrictHttpResponse): Platform => r.body); + } + + /** Path part for operation `getPlatform()` */ + static readonly GetPlatformPath = '/api/platforms/{id}'; + + /** + * Get Platform. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `getPlatform()` instead. + * + * This method doesn't expect any request body. + */ + getPlatform$Response(params: GetPlatform$Params, context?: HttpContext): Promise> { + const obs = getPlatform(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Get Platform. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `getPlatform$Response()` instead. + * + * This method doesn't expect any request body. + */ + getPlatform(params: GetPlatform$Params, context?: HttpContext): Promise { + const resp = this.getPlatform$Response(params, context); + return resp.then((r: StrictHttpResponse): Platform => r.body); + } + + /** Path part for operation `updatePlatform()` */ + static readonly UpdatePlatformPath = '/api/platforms/{id}'; + + /** + * Update Platform. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `updatePlatform()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + updatePlatform$Response(params: UpdatePlatform$Params, context?: HttpContext): Promise> { + const obs = updatePlatform(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Update Platform. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `updatePlatform$Response()` instead. + * + * This method sends `application/json` and handles request body of type `application/json`. + */ + updatePlatform(params: UpdatePlatform$Params, context?: HttpContext): Promise { + const resp = this.updatePlatform$Response(params, context); + return resp.then((r: StrictHttpResponse): Platform => r.body); + } + + /** Path part for operation `deletePlatform()` */ + static readonly DeletePlatformPath = '/api/platforms/{id}'; + + /** + * Delete Platform. + * + * + * + * This method provides access to the full `HttpResponse`, allowing access to response headers. + * To access only the response body, use `deletePlatform()` instead. + * + * This method doesn't expect any request body. + */ + deletePlatform$Response(params: DeletePlatform$Params, context?: HttpContext): Promise> { + const obs = deletePlatform(this.http, this.rootUrl, params, context); + return firstValueFrom(obs); + } + + /** + * Delete Platform. + * + * + * + * This method provides access only to the response body. + * To access the full response (for headers, for example), `deletePlatform$Response()` instead. + * + * This method doesn't expect any request body. + */ + deletePlatform(params: DeletePlatform$Params, context?: HttpContext): Promise { + const resp = this.deletePlatform$Response(params, context); + return resp.then((r: StrictHttpResponse): void => r.body); + } + +} diff --git a/web/src/app/api/strict-http-response.ts b/web/src/app/api/strict-http-response.ts new file mode 100644 index 0000000..174f119 --- /dev/null +++ b/web/src/app/api/strict-http-response.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +/* Code generated by ng-openapi-gen DO NOT EDIT. */ + +import { HttpResponse } from '@angular/common/http'; + +/** + * Constrains the http response to not have the body defined as `T | null`, but `T` only. + */ +export type StrictHttpResponse = HttpResponse & { + readonly body: T; +} diff --git a/web/src/app/app.config.ts b/web/src/app/app.config.ts new file mode 100644 index 0000000..b811d48 --- /dev/null +++ b/web/src/app/app.config.ts @@ -0,0 +1,24 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { authInterceptor } from './core/auth-interceptor'; +import { provideApiConfiguration } from './api/api-configuration'; + +// The API's base URL. There's no server-side config to read it from at +// runtime in this small base (a plain SPA build), so it's a build-time +// constant; swap to Angular's fileReplacements if a later build needs a +// different URL per environment. +const API_BASE_URL = 'http://localhost:8080'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideAnimationsAsync(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + provideApiConfiguration(API_BASE_URL), + ], +}; diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts new file mode 100644 index 0000000..6ea995f --- /dev/null +++ b/web/src/app/app.routes.ts @@ -0,0 +1,16 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from './core/auth-guard'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'games' }, + { + path: 'login', + loadComponent: () => import('./features/login/login').then((m) => m.Login), + }, + { + path: 'games', + loadComponent: () => import('./features/games/games').then((m) => m.Games), + canActivate: [authGuard], + }, +]; diff --git a/web/src/app/app.spec.ts b/web/src/app/app.spec.ts new file mode 100644 index 0000000..c16155b --- /dev/null +++ b/web/src/app/app.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/web/src/app/app.ts b/web/src/app/app.ts new file mode 100644 index 0000000..759d8dc --- /dev/null +++ b/web/src/app/app.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + imports: [RouterOutlet], + selector: 'app-root', + template: '', +}) +export class App {} diff --git a/web/src/app/core/auth-guard.spec.ts b/web/src/app/core/auth-guard.spec.ts new file mode 100644 index 0000000..285ea8c --- /dev/null +++ b/web/src/app/core/auth-guard.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router, UrlTree } from '@angular/router'; +import { provideRouter } from '@angular/router'; + +import { authGuard } from './auth-guard'; +import { Session } from './session'; +import { provideApiConfiguration } from '../api/api-configuration'; + +describe('authGuard', () => { + let session: Session; + let router: Router; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideApiConfiguration('http://api.test'), + provideRouter([]), + ], + }); + session = TestBed.inject(Session); + router = TestBed.inject(Router); + }); + + function runGuard() { + return TestBed.runInInjectionContext(() => authGuard({} as any, {} as any)); + } + + it('allows navigation when authenticated', () => { + session.token.set('tok-abc'); + expect(runGuard()).toBe(true); + }); + + it('redirects to /login when not authenticated', () => { + const result = runGuard(); + expect(result).not.toBe(true); + expect((result as UrlTree).toString()).toBe(router.createUrlTree(['/login']).toString()); + }); +}); diff --git a/web/src/app/core/auth-guard.ts b/web/src/app/core/auth-guard.ts new file mode 100644 index 0000000..abd4fcf --- /dev/null +++ b/web/src/app/core/auth-guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +import { Session } from './session'; + +/** Redirects to /login unless a session token is present. */ +export const authGuard: CanActivateFn = () => { + if (inject(Session).isAuthenticated()) { + return true; + } + return inject(Router).createUrlTree(['/login']); +}; diff --git a/web/src/app/core/auth-interceptor.spec.ts b/web/src/app/core/auth-interceptor.spec.ts new file mode 100644 index 0000000..198ed1e --- /dev/null +++ b/web/src/app/core/auth-interceptor.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { authInterceptor } from './auth-interceptor'; +import { Session } from './session'; +import { provideApiConfiguration } from '../api/api-configuration'; + +describe('authInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let session: Session; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + provideApiConfiguration('http://api.test'), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + session = TestBed.inject(Session); + }); + + afterEach(() => httpMock.verify()); + + it('attaches the bearer token when a session is present', () => { + session.token.set('tok-abc'); + + http.get('/whatever').subscribe(); + + const req = httpMock.expectOne('/whatever'); + expect(req.request.headers.get('Authorization')).toBe('Bearer tok-abc'); + }); + + it('sends no Authorization header when logged out', () => { + http.get('/whatever').subscribe(); + + const req = httpMock.expectOne('/whatever'); + expect(req.request.headers.has('Authorization')).toBe(false); + }); +}); diff --git a/web/src/app/core/auth-interceptor.ts b/web/src/app/core/auth-interceptor.ts new file mode 100644 index 0000000..c359e61 --- /dev/null +++ b/web/src/app/core/auth-interceptor.ts @@ -0,0 +1,13 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; + +import { Session } from './session'; + +/** Attaches the stored session token to every outgoing API request. */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const token = inject(Session).token(); + if (!token) { + return next(req); + } + return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); +}; diff --git a/web/src/app/core/session.spec.ts b/web/src/app/core/session.spec.ts new file mode 100644 index 0000000..0be420e --- /dev/null +++ b/web/src/app/core/session.spec.ts @@ -0,0 +1,90 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; + +import { Session } from './session'; +import { provideApiConfiguration } from '../api/api-configuration'; + +describe('Session', () => { + let service: Session; + let httpMock: HttpTestingController; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')], + }); + service = TestBed.inject(Session); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('starts logged out with no stored token', () => { + expect(service.isAuthenticated()).toBe(false); + expect(service.user()).toBeNull(); + }); + + it('restores the token from a previous session', () => { + localStorage.setItem('completed.token', 'tok-123'); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideApiConfiguration('http://api.test')], + }); + const restored = TestBed.inject(Session); + expect(restored.isAuthenticated()).toBe(true); + }); + + it('login stores the token and user, and persists to localStorage', async () => { + const promise = service.login('alice@example.com', 'secret'); + + const req = httpMock.expectOne('http://api.test/api/auth/login'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ email: 'alice@example.com', password: 'secret' }); + req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } }); + + await promise; + + expect(service.token()).toBe('tok-abc'); + expect(service.user()?.name).toBe('Alice'); + expect(service.isAdmin()).toBe(false); + expect(localStorage.getItem('completed.token')).toBe('tok-abc'); + }); + + it('login failure leaves the session logged out', async () => { + const promise = service.login('alice@example.com', 'wrong'); + + const req = httpMock.expectOne('http://api.test/api/auth/login'); + req.flush({ title: 'Unauthorized', status: 401, detail: 'invalid email or password' }, { status: 401, statusText: 'Unauthorized' }); + + await expect(promise).rejects.toBeTruthy(); + expect(service.isAuthenticated()).toBe(false); + }); + + it('logout clears the session even if the request fails', async () => { + service.token.set('tok-abc'); + service.user.set({ id: 'u1', email: 'a@example.com', name: 'Alice', is_admin: false }); + + const promise = service.logout(); + const req = httpMock.expectOne('http://api.test/api/auth/logout'); + req.flush(null, { status: 500, statusText: 'Internal Server Error' }); + + await expect(promise).rejects.toBeTruthy(); + expect(service.isAuthenticated()).toBe(false); + expect(service.user()).toBeNull(); + expect(localStorage.getItem('completed.token')).toBeNull(); + }); + + it('restore() clears an invalid stored token instead of leaving a broken session', async () => { + service.token.set('tok-stale'); + + const promise = service.restore(); + const req = httpMock.expectOne('http://api.test/api/auth/me'); + req.flush({ title: 'Unauthorized', status: 401, detail: 'session expired' }, { status: 401, statusText: 'Unauthorized' }); + + await promise; + expect(service.isAuthenticated()).toBe(false); + }); +}); diff --git a/web/src/app/core/session.ts b/web/src/app/core/session.ts new file mode 100644 index 0000000..70114a5 --- /dev/null +++ b/web/src/app/core/session.ts @@ -0,0 +1,92 @@ +import { Service, computed, inject, signal } from '@angular/core'; + +import { AuthService } from '../api/services/auth.service'; +import { User } from '../api/models/user'; + +/** Where the token is kept so a page refresh doesn't log the user out. */ +const STORAGE_KEY = 'completed.token'; + +/** + * Holds the current login state (token + user) and is the one place that + * talks to the generated AuthService. Components read `user()` / + * `isAdmin()` and call `login`/`register`/`logout`; nothing else touches + * localStorage or the auth endpoints directly. + */ +@Service() +export class Session { + private readonly auth = inject(AuthService); + + readonly user = signal(null); + readonly token = signal(this.readStoredToken()); + readonly isAuthenticated = computed(() => this.token() !== null); + readonly isAdmin = computed(() => this.user()?.is_admin ?? false); + + // A stored token with no user yet (e.g. after a page refresh) is + // resolved lazily by whoever needs `user()` first - in this small base, + // the games page calls `restore()` on init. + + private readStoredToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + // localStorage can throw in some privacy modes; fall back to + // "logged out" rather than crashing the app. + return null; + } + } + + private storeToken(token: string | null): void { + try { + if (token) { + localStorage.setItem(STORAGE_KEY, token); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // Same as above: a storage failure shouldn't break login/logout. + } + } + + /** Re-fetches the current user for a token restored from storage. */ + async restore(): Promise { + if (!this.token() || this.user()) { + return; + } + try { + const user = await this.auth.getMe(); + this.user.set(user); + } catch { + // The stored token is no longer valid (expired/revoked); drop it so + // the app shows the login screen instead of a broken "logged in". + this.clear(); + } + } + + async login(email: string, password: string): Promise { + const resp = await this.auth.login({ body: { email, password } }); + this.token.set(resp.token); + this.user.set(resp.user); + this.storeToken(resp.token); + } + + async register(email: string, password: string, name: string): Promise { + const resp = await this.auth.register({ body: { email, password, name } }); + this.token.set(resp.token); + this.user.set(resp.user); + this.storeToken(resp.token); + } + + async logout(): Promise { + try { + await this.auth.logout(); + } finally { + this.clear(); + } + } + + private clear(): void { + this.token.set(null); + this.user.set(null); + this.storeToken(null); + } +} diff --git a/web/src/app/features/games/games.css b/web/src/app/features/games/games.css new file mode 100644 index 0000000..e666a24 --- /dev/null +++ b/web/src/app/features/games/games.css @@ -0,0 +1,26 @@ +.spacer { + flex: 1 1 auto; +} + +.user-name { + margin-right: 1rem; + opacity: 0.8; +} + +.content { + padding: 1.5rem; +} + +.games-table { + width: 100%; +} + +.centered { + display: flex; + justify-content: center; + padding-top: 3rem; +} + +.error { + color: var(--mat-sys-error, #b3261e); +} diff --git a/web/src/app/features/games/games.html b/web/src/app/features/games/games.html new file mode 100644 index 0000000..9ace228 --- /dev/null +++ b/web/src/app/features/games/games.html @@ -0,0 +1,51 @@ + + Completed + + @if (session.user()) { + {{ session.user()!.name }} + } + + + + +
+ @if (loading()) { +
+ } @else if (error()) { +

{{ error() }}

+ } @else if (games().length === 0) { +

No games yet.

+ } @else { + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ g.name }}Platform{{ g.platform_name }}Score{{ g.score }}Released{{ g.release_date }}Finished{{ g.finished ? '✓' : '' }}
+ } +
diff --git a/web/src/app/features/games/games.spec.ts b/web/src/app/features/games/games.spec.ts new file mode 100644 index 0000000..06f37d2 --- /dev/null +++ b/web/src/app/features/games/games.spec.ts @@ -0,0 +1,109 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router, provideRouter } from '@angular/router'; + +import { Games } from './games'; +import { Session } from '../../core/session'; +import { provideApiConfiguration } from '../../api/api-configuration'; + +describe('Games', () => { + let component: Games; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let session: Session; + let router: Router; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [Games], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideApiConfiguration('http://api.test'), + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Games); + component = fixture.componentInstance; + httpMock = TestBed.inject(HttpTestingController); + session = TestBed.inject(Session); + router = TestBed.inject(Router); + }); + + afterEach(() => httpMock.verify()); + + // Setting both token and user means `session.restore()` (called by + // ngOnInit) finds a user already present and skips its own HTTP call, + // so each test only has to deal with the request it's actually testing. + function logIn(): void { + session.token.set('tok-abc'); + session.user.set({ id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false }); + } + + // ngOnInit awaits session.restore() before calling refresh(), and + // restore() returns early (no HTTP call) once a user is already set - + // but that early return is still through an async function, so it still + // takes one microtask tick before ngOnInit resumes far enough to + // actually dispatch the /api/games request. + async function flushMicrotask(): Promise { + await Promise.resolve(); + } + + it('loads games on init', async () => { + logIn(); + const initPromise = component.ngOnInit(); + await flushMicrotask(); + + const req = httpMock.expectOne('http://api.test/api/games'); + req.flush([{ id: 'g1', name: 'Doom', platform_id: 'p1', platform_name: 'PC', score: 95, release_date: '1993-12-10' }]); + await initPromise; + + expect(component.games()).toHaveLength(1); + expect(component.games()[0].name).toBe('Doom'); + expect(component.error()).toBeNull(); + expect(component.loading()).toBe(false); + }); + + it('shows an error when the request fails', async () => { + logIn(); + const initPromise = component.ngOnInit(); + await flushMicrotask(); + + const req = httpMock.expectOne('http://api.test/api/games'); + req.flush({ title: 'Internal Server Error', status: 500 }, { status: 500, statusText: 'Internal Server Error' }); + await initPromise; + + expect(component.error()).toContain('Could not load games'); + expect(component.games()).toHaveLength(0); + }); + + it('refresh() re-fetches the list', async () => { + logIn(); + const initPromise = component.ngOnInit(); + await flushMicrotask(); + httpMock.expectOne('http://api.test/api/games').flush([]); + await initPromise; + + const refreshPromise = component.refresh(); + const req = httpMock.expectOne('http://api.test/api/games'); + req.flush([{ id: 'g1', name: 'Half-Life', platform_id: 'p1', platform_name: 'PC', score: 96, release_date: '1998-11-19' }]); + await refreshPromise; + + expect(component.games()).toHaveLength(1); + }); + + it('logout() clears the session and navigates to /login', async () => { + logIn(); + const navigateSpy = vi.spyOn(router, 'navigateByUrl'); + + const logoutPromise = component.logout(); + httpMock.expectOne('http://api.test/api/auth/logout').flush(null); + await logoutPromise; + + expect(session.isAuthenticated()).toBe(false); + expect(navigateSpy).toHaveBeenCalledWith('/login'); + }); +}); diff --git a/web/src/app/features/games/games.ts b/web/src/app/features/games/games.ts new file mode 100644 index 0000000..ec6c906 --- /dev/null +++ b/web/src/app/features/games/games.ts @@ -0,0 +1,58 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { MatToolbarModule } from '@angular/material/toolbar'; + +import { GamesService } from '../../api/services/games.service'; +import { Game } from '../../api/models/game'; +import { Session } from '../../core/session'; + +@Component({ + imports: [ + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatTableModule, + MatToolbarModule, + ], + selector: 'app-games', + styleUrl: './games.css', + templateUrl: './games.html', +}) +export class Games implements OnInit { + readonly columns = ['name', 'platform_name', 'score', 'release_date', 'finished']; + readonly games = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + constructor( + readonly session: Session, + private readonly gamesService: GamesService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + await this.session.restore(); + await this.refresh(); + } + + async refresh(): Promise { + this.loading.set(true); + this.error.set(null); + try { + this.games.set((await this.gamesService.listGames()) ?? []); + } catch { + this.error.set('Could not load games. Check your connection and try again.'); + } finally { + this.loading.set(false); + } + } + + async logout(): Promise { + await this.session.logout(); + this.router.navigateByUrl('/login'); + } +} diff --git a/web/src/app/features/login/login.css b/web/src/app/features/login/login.css new file mode 100644 index 0000000..ebb7c56 --- /dev/null +++ b/web/src/app/features/login/login.css @@ -0,0 +1,18 @@ +.login-page { + display: flex; + justify-content: center; + padding-top: 10vh; +} + +.login-card { + width: 100%; + max-width: 360px; +} + +.full-width { + width: 100%; +} + +.error { + color: var(--mat-sys-error, #b3261e); +} diff --git a/web/src/app/features/login/login.html b/web/src/app/features/login/login.html new file mode 100644 index 0000000..8934bfd --- /dev/null +++ b/web/src/app/features/login/login.html @@ -0,0 +1,32 @@ + diff --git a/web/src/app/features/login/login.spec.ts b/web/src/app/features/login/login.spec.ts new file mode 100644 index 0000000..845116a --- /dev/null +++ b/web/src/app/features/login/login.spec.ts @@ -0,0 +1,84 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router, provideRouter } from '@angular/router'; + +import { Login } from './login'; +import { provideApiConfiguration } from '../../api/api-configuration'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let router: Router; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [Login], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideApiConfiguration('http://api.test'), + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + httpMock = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + await fixture.whenStable(); + }); + + afterEach(() => httpMock.verify()); + + it('is invalid with empty fields', () => { + expect(component.form.invalid).toBe(true); + }); + + it('is valid with a well-formed email and non-empty password', () => { + component.form.setValue({ email: 'alice@example.com', password: 'secret' }); + expect(component.form.valid).toBe(true); + }); + + it('rejects a malformed email', () => { + component.form.setValue({ email: 'not-an-email', password: 'secret' }); + expect(component.form.valid).toBe(false); + }); + + it('on success, logs in and navigates to /games', async () => { + const navigateSpy = vi.spyOn(router, 'navigateByUrl'); + component.form.setValue({ email: 'alice@example.com', password: 'secret' }); + + const submitPromise = component.submit(); + const req = httpMock.expectOne('http://api.test/api/auth/login'); + req.flush({ token: 'tok-abc', user: { id: 'u1', email: 'alice@example.com', name: 'Alice', is_admin: false } }); + await submitPromise; + + expect(navigateSpy).toHaveBeenCalledWith('/games'); + expect(component.error()).toBeNull(); + }); + + it('on failure, shows an error and does not navigate', async () => { + const navigateSpy = vi.spyOn(router, 'navigateByUrl'); + component.form.setValue({ email: 'alice@example.com', password: 'wrong' }); + + const submitPromise = component.submit(); + const req = httpMock.expectOne('http://api.test/api/auth/login'); + req.flush( + { title: 'Unauthorized', status: 401, detail: 'invalid email or password' }, + { status: 401, statusText: 'Unauthorized' }, + ); + await submitPromise; + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.error()).toBe('invalid email or password'); + }); + + it('does nothing when the form is invalid', async () => { + component.form.setValue({ email: '', password: '' }); + await component.submit(); + httpMock.expectNone('http://api.test/api/auth/login'); + }); +}); diff --git a/web/src/app/features/login/login.ts b/web/src/app/features/login/login.ts new file mode 100644 index 0000000..193c6f2 --- /dev/null +++ b/web/src/app/features/login/login.ts @@ -0,0 +1,68 @@ +import { Component, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +import { Session } from '../../core/session'; + +@Component({ + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + ], + selector: 'app-login', + styleUrl: './login.css', + templateUrl: './login.html', +}) +export class Login { + readonly form = new FormBuilder().group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + readonly submitting = signal(false); + readonly error = signal(null); + + constructor( + private readonly session: Session, + private readonly router: Router, + ) {} + + async submit(): Promise { + if (this.form.invalid || this.submitting()) { + return; + } + const { email, password } = this.form.getRawValue(); + + this.submitting.set(true); + this.error.set(null); + try { + await this.session.login(email!, password!); + this.router.navigateByUrl('/games'); + } catch (err) { + this.error.set(errorMessage(err)); + } finally { + this.submitting.set(false); + } + } +} + +/** Pulls a server-provided message out of the generated client's error + * shape, falling back to a generic one for network failures etc. */ +function errorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'error' in err) { + const body = (err as { error?: unknown }).error; + if (body && typeof body === 'object' && 'detail' in body && typeof (body as any).detail === 'string') { + return (body as any).detail; + } + } + return 'Could not log in. Check your connection and try again.'; +} diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..2271996 --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,23 @@ + + + + + Completed + + + + + + + + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/web/src/material-theme.scss b/web/src/material-theme.scss new file mode 100644 index 0000000..2ccd444 --- /dev/null +++ b/web/src/material-theme.scss @@ -0,0 +1,38 @@ +// Include theming for Angular Material with `mat.theme()`. +// This Sass mixin will define CSS variables that are used for styling Angular Material +// components according to the Material 3 design spec. +// Learn more about theming and how to use it for your application's +// custom components at https://material.angular.dev/guide/theming +@use '@angular/material' as mat; + +html { + height: 100%; + @include mat.theme( + ( + color: ( + primary: mat.$azure-palette, + tertiary: mat.$blue-palette, + ), + typography: Roboto, + density: 0, + ) + ); +} + +body { + // Default the application to a light color theme. This can be changed to + // `dark` to enable the dark color theme, or to `light dark` to defer to the + // user's system settings. + color-scheme: light; + + // Set a default background, font and text colors for the application using + // Angular Material's system-level CSS variables. Learn more about these + // variables at https://material.angular.dev/guide/system-variables + background-color: var(--mat-sys-surface); + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-medium); + + // Reset the user agent margin. + margin: 0; + height: 100%; +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..90d4ee0 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..cb151e1 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/web/tsconfig.spec.json b/web/tsconfig.spec.json new file mode 100644 index 0000000..9c8efb9 --- /dev/null +++ b/web/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} From 786fdbb0d5d2ba9caa8577e4d8d6054590d58e9e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 12:49:36 +0200 Subject: [PATCH 5/5] Dockerize the API with an embedded web UI, Fyne-for-Windows, and real releases cmd/api/main.go: registerWebUI serves a built web/ as a single-page app - Echo's static middleware in HTML5 mode, with a Skipper that exempts /api/*, /hello, /docs, /schemas/* and openapi.json/.yaml so it can never shadow a real API route. It reads WEB_DIST_DIR (default /frontend, where the Dockerfile puts it) and no-ops if that directory doesn't exist, so `go run ./cmd/api` and CI stay Node-free and API-only; only the Docker image actually turns the web UI on. Dockerfile: new node:22-alpine stage builds web/, copied into the final image at /frontend alongside the existing Go-built binary. Added .dockerignore (web/node_modules, web/dist, web/.angular, .git) now that a second language's tooling is in the build context. Looked at how your other Gitea projects (MusicServer et al.) structure this, since I can't exercise Gitea Actions itself from here: MusicServer's own Dockerfile already does "copy a prebuilt frontend into the Go image, serve it from Go" (there, from a separately-published frontend image) - registerWebUI does the same thing in spirit, just within one image rather than two, since this UI isn't meant to be published separately. release.yml: - build-gui now also cross-compiles the Fyne GUI for windows/amd64 via mingw-w64, alongside the existing linux/amd64. Verified for real, not just written: installed mingw-w64 locally and cross-built cmd/gui, first plain (got a working PE32+ console exe), then with -ldflags "-H=windowsgui" (got the GUI-subsystem exe actually wanted, no console window). macOS isn't attempted - cgo has no equivalent cross-toolchain for Darwin and there's no registered macOS runner here. - new build-docker job pushes the API image (now carrying the web build) to gitea.sanplex.xyz/sansan/completed, tagged with the version and latest. - replaced the hand-rolled curl+jq Gitea-release creation with akkuman/gitea-release-action, and artifact upload with the Gitea generic package registry - both are what your other projects (HanjaKanaMun, MusicPlayer) actually use, found by reading their workflows rather than guessing. Same for the registry host, owner, and secrets.TOKEN name (previously secrets.GITHUB_TOKEN, which nothing else here uses). ci.yml: new docker job builds the same image on every push to main/develop (push: false) as a health check, matching MusicServer's build-vs-release split. Verified for real wherever I could: a full `docker build` of the new Dockerfile, then running that image against a real Postgres container and hitting it with curl - / serves the actual built Angular index.html, /games (a client-side route) correctly falls back to it, /api/games and /hello are untouched (still return real API responses, not shadowed by the static handler), and register+list-games works end to end through the container. Also ran it the documented way, `docker compose up --build`, with the same results. Separately confirmed the Windows Fyne cross-build produces a real, correctly-subsystemed .exe. What I could not run from here is Gitea Actions itself - the workflow YAML is written to match your other projects' established, working conventions, but its first real execution on your Gitea instance is still the first real test of it. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 6 ++ .gitea/workflows/ci.yml | 17 ++++ .gitea/workflows/release.yml | 155 +++++++++++++++++++++++------------ Dockerfile | 29 ++++++- README.md | 52 +++++++++--- cmd/api/main.go | 40 +++++++++ 6 files changed, 231 insertions(+), 68 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d01fd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +bin/ + +web/node_modules +web/dist +web/.angular diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d67ddf4..bf1e7a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -79,3 +79,20 @@ jobs: - name: Test run: npm test + + # Builds the image on every push as a health check (catches a broken + # Dockerfile immediately); doesn't push anywhere. release.yml does the + # same build and pushes it. + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: false diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 13136d8..ab73d47 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,12 +1,21 @@ +# +# .gitea/workflows/release.yml +# +# On a version tag (e.g. v1.2.3): builds every client, pushes the API's +# Docker image, uploads all build artifacts to the Gitea generic package +# registry, and creates a Gitea release with those same files attached. +# name: Release -# A pushed tag like v1.2.3 builds every client and attaches the result to a -# Gitea release of the same name. Tag a commit that has already passed CI -# on main; this workflow doesn't re-run those checks. on: push: tags: ["v*.*.*"] +env: + PROJECT_NAME: Completed + REGISTRY: gitea.sanplex.xyz + REGISTRY_OWNER: sansan + jobs: # cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly # from one Linux runner for every OS/arch below. @@ -47,7 +56,7 @@ jobs: - name: Archive run: | cd dist - base="completed_${{ matrix.goos }}_${{ matrix.goarch }}" + base="${PROJECT_NAME}_${{ matrix.goos }}_${{ matrix.goarch }}" if [ "${{ matrix.goos }}" = "windows" ]; then zip "../${base}.zip" completed-api.exe completed-tui.exe else @@ -56,18 +65,29 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: completed_${{ matrix.goos }}_${{ matrix.goarch }} - path: completed_${{ matrix.goos }}_${{ matrix.goarch }}.* + name: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }} + path: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }}.* retention-days: 7 - # cmd/gui (Fyne) uses cgo and links the system's native GL/windowing - # libraries, so - unlike build-go above - it can't be cross-compiled for - # other operating systems from this one Linux runner. This job only - # produces a linux/amd64 build. A macOS or Windows build of the GUI would - # need this workflow extended with a job running on a matching - # self-hosted runner (there isn't one registered for this repo today). - build-gui-linux: + # cmd/gui (Fyne) uses cgo, so unlike build-go above it can't just cross- + # compile with CGO_ENABLED=0. Linux is native to this runner; Windows + # cross-compiles cleanly with the mingw-w64 toolchain (verified locally: + # a real PE32+ GUI-subsystem .exe, linking Fyne's OpenGL/glfw backend, + # with no changes needed beyond CC and the usual GOOS/GOARCH). macOS + # isn't included - cross-compiling cgo to Darwin from Linux has no + # equivalent well-supported toolchain, so that would need an actual + # macOS runner, which isn't registered for this repo. + build-gui: runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + packages: libgl1-mesa-dev xorg-dev + - goos: windows + goarch: amd64 + packages: gcc-mingw-w64-x86-64 steps: - uses: actions/checkout@v4 @@ -78,20 +98,35 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev xorg-dev + sudo apt-get install -y ${{ matrix.packages }} - name: Build gui + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "1" run: | mkdir -p dist - go build -o dist/completed-gui ./cmd/gui + if [ "${{ matrix.goos }}" = "windows" ]; then + CC=x86_64-w64-mingw32-gcc go build -ldflags "-H=windowsgui" -o dist/completed-gui.exe ./cmd/gui + else + go build -o dist/completed-gui ./cmd/gui + fi - name: Archive - run: tar czf completed-gui_linux_amd64.tar.gz -C dist completed-gui + run: | + cd dist + base="../${PROJECT_NAME}-gui_${{ matrix.goos }}_${{ matrix.goarch }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + zip "${base}.zip" completed-gui.exe + else + tar czf "${base}.tar.gz" completed-gui + fi - uses: actions/upload-artifact@v4 with: - name: completed-gui_linux_amd64 - path: completed-gui_linux_amd64.tar.gz + name: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }} + path: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }}.* retention-days: 7 build-web: @@ -112,57 +147,71 @@ jobs: npm run build - name: Archive - run: tar czf completed-web_dist.tar.gz -C web/dist/web . + run: tar czf ${PROJECT_NAME}-web_dist.tar.gz -C web/dist/web/browser . - uses: actions/upload-artifact@v4 with: - name: completed-web_dist - path: completed-web_dist.tar.gz + name: ${{ env.PROJECT_NAME }}-web_dist + path: ${{ env.PROJECT_NAME }}-web_dist.tar.gz retention-days: 7 - release: - needs: [build-go, build-gui-linux, build-web] + # Builds and pushes the API's image, which embeds the web build as its + # static UI (see the Dockerfile and cmd/api/main.go's registerWebUI). + build-docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}"] + http = true + insecure = true + + - name: Login to Gitea + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:${{ gitea.ref_name }} + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:latest + + release: + needs: [build-go, build-gui, build-web] + runs-on: ubuntu-latest + steps: - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true - - name: Create the Gitea release and upload each artifact + - name: Upload to the Gitea generic package registry env: - # Gitea Actions auto-provides this for the compatibility GITHUB_* - # context; it needs the repo's Settings > Actions permissions set - # to allow writing contents. If your instance doesn't populate it, - # replace with a secret holding a personal/repo access token that - # has repo write access instead. - GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SERVER_URL: ${{ github.server_url }} - REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} + VERSION: ${{ gitea.ref_name }} run: | - set -euo pipefail - sudo apt-get update && sudo apt-get install -y jq - - release_json=$(curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \ - "${SERVER_URL}/api/v1/repos/${REPO}/releases") - release_id=$(echo "$release_json" | jq -r '.id') - - if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then - echo "Failed to create release: $release_json" >&2 - exit 1 - fi - for f in artifacts/*; do - name=$(basename "$f") + name="$(basename "$f")" echo "Uploading $name" - curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -F "attachment=@${f}" \ - "${SERVER_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${name}" + curl --fail --user "${{ github.repository_owner }}:${{ secrets.TOKEN }}" \ + --upload-file "$f" \ + "https://${{ env.REGISTRY }}/api/packages/${{ env.REGISTRY_OWNER }}/generic/${{ env.PROJECT_NAME }}/${VERSION}/${name}" done + + - name: Create release + uses: akkuman/gitea-release-action@v1 + env: + NODE_OPTIONS: "--experimental-fetch" # if nodejs < 18 + with: + files: |- + artifacts/** diff --git a/Dockerfile b/Dockerfile index 78c9e01..3439af2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,18 @@ -# Build Stage -FROM golang:1.25-alpine AS builder +# Web build stage - the API serves this directory itself (see +# registerWebUI in cmd/api/main.go); there's no separate nginx/web image. +FROM node:22-alpine AS build_web + +WORKDIR /app/web + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build +# Output: /app/web/dist/web/browser + +# API build stage +FROM golang:1.25-alpine AS build_go WORKDIR /app @@ -29,8 +42,15 @@ WORKDIR /app RUN apk add --no-cache ca-certificates # Copy the binary from the builder stage. Migrations are embedded in the -# binary (see db/migrations/embed.go), so there's nothing else to copy. -COPY --from=builder /app/api-server . +# binary (see db/migrations/embed.go), so there's nothing else to copy for +# the API itself. +COPY --from=build_go /app/api-server . + +# The web app's build output. registerWebUI (cmd/api/main.go) serves this +# directory as a single-page app, with unknown paths falling back to +# index.html for Angular's router; it no-ops if WEB_DIST_DIR is missing, so +# this layer is the only thing that turns that serving on. +COPY --from=build_web /app/web/dist/web/browser /frontend # Expose the application port EXPOSE 8080 @@ -38,6 +58,7 @@ EXPOSE 8080 # Environment variables with defaults (can be overridden) ENV PORT=8080 ENV DATABASE_URL="" +ENV WEB_DIST_DIR=/frontend # Run the binary CMD ["./api-server"] diff --git a/README.md b/README.md index 9dbef52..fe0af34 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ your score for it, and whether (and when) you finished it. Each account has its own private game list, but all accounts share one platform list (only an admin can add/rename/delete a platform - the first -account ever registered becomes an admin automatically). The API is usable. -There are three small-base clients: a terminal UI, a desktop GUI (Fyne), -and a web app (Angular) — each can log in and show your games, with more -to come. +account ever registered becomes an admin automatically). The API is usable, +and serves its own web UI (see below). There are three small-base clients: +a terminal UI, a desktop GUI (Fyne), and a web app (Angular) — each can log +in and show your games, with more to come. ## Running it @@ -27,7 +27,14 @@ just run-api ``` The API creates its database and runs migrations on startup if needed. -Browse `http://localhost:8080/docs` for the generated API docs. +Browse `http://localhost:8080/docs` for the generated API docs, or +`http://localhost:8080/` for the web UI - the Docker image bakes in a +production build of `web/` and the API serves it itself (see +`registerWebUI` in `cmd/api/main.go`); unknown paths fall back to its +`index.html` so Angular's router handles deep links like `/games`. Running +the API outside Docker (`just run-api`) skips this and serves the API +only, unless `WEB_DIST_DIR` points at a build of `web/` (`just web-build` +outputs one to `web/dist/web/browser`). ## Using the API @@ -58,6 +65,9 @@ also set an httpOnly `session` cookie. - `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`, proxying nothing itself; it talks directly to the API at `localhost:8080` (CORS is permissive enough for local dev as-is). Log in, see your games. + This is for frontend development with hot reload; the API's own `/` (see + above) serves a production build of the same app with nothing extra to + run. All three point at `http://localhost:8080` by default. The TUI and GUI read `COMPLETED_API_URL` to use a different API; the web app's URL is a build-time @@ -87,9 +97,29 @@ shouldn't be hand-edited. ## CI/CD -`.gitea/workflows/ci.yml` builds and tests everything (Go + web) on every -push to `main` or `develop`. `.gitea/workflows/release.yml` builds all three -clients and attaches them to a Gitea release when a `vX.Y.Z` tag is pushed; -see that file's comments for its one real limitation (the desktop GUI is -only built for linux/amd64, since it needs cgo and this repo has no -registered macOS/Windows runner to cross-build it on). +`.gitea/workflows/ci.yml` builds and tests everything on every push to +`main` or `develop`: Go (gofmt/vet/build/test/tidy), sqlc, the web app, and +a Docker build-only check (the same image `release.yml` later pushes). + +`.gitea/workflows/release.yml` runs on a `vX.Y.Z` tag: +- cross-compiles `completed-api`/`completed-tui` for linux, darwin and + windows (amd64 + arm64 where it makes sense - both are pure Go, no cgo) +- cross-compiles the Fyne GUI for linux/amd64 (native) and windows/amd64 + (via mingw-w64; verified locally - a real GUI-subsystem `.exe`, no + console window). macOS isn't built, since cgo has no equivalent + cross-toolchain and this repo has no registered macOS runner +- builds the web app +- builds and pushes the API's Docker image (which embeds that same web + build) to `gitea.sanplex.xyz/sansan/completed`, tagged with the version + and `latest` +- uploads every build artifact to the Gitea generic package registry and + attaches them to a Gitea release + +Both workflows were run for real against this repo - the Go test suite, +`sqlc diff`, `go mod tidy -diff`, the Angular build and test suite, the +Fyne-for-Windows cross-compile, and the actual multi-stage Docker build +(built, run against a real Postgres container, and hit with curl) all +passed. The workflow YAML itself (trigger/job wiring, the Gitea API calls +for uploading packages and creating the release) is written to match this +Gitea instance's other projects, but wasn't run through Gitea Actions +itself from this environment - worth watching the first real run of each. diff --git a/cmd/api/main.go b/cmd/api/main.go index 9dce95a..c23b0ff 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -96,6 +96,45 @@ func runMigrations(dbURL string) error { return nil } +// reservedAPIPrefixes are the paths the API itself owns: everything under +// /api, plus what Huma's default config registers for docs/schemas. The +// web UI's SPA fallback (below) must never shadow these. +var reservedAPIPrefixes = []string{"/api/", "/docs", "/schemas/", "/openapi.json", "/openapi.yaml", "/hello"} + +// registerWebUI serves the Angular build as a single-page app if one has +// been placed at dir (WEB_DIST_DIR, default /frontend - matching where the +// Dockerfile's web build stage copies it to). Unknown paths fall back to +// index.html so Angular's router can handle a direct link to e.g. /games. +// +// Outside the Docker image, dir won't exist; this is a deliberate no-op in +// that case; so `go run ./cmd/api` and the CI build keep working without +// ever needing Node, and the API alone can still be deployed without a UI. +func registerWebUI(e *echo.Echo) { + dir := os.Getenv("WEB_DIST_DIR") + if dir == "" { + dir = "/frontend" + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + fmt.Printf("WEB_DIST_DIR %q not found; serving the API only, no web UI.\n", dir) + return + } + + e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ + Root: dir, + HTML5: true, + Skipper: func(c echo.Context) bool { + p := c.Request().URL.Path + for _, prefix := range reservedAPIPrefixes { + if strings.HasPrefix(p, prefix) { + return true + } + } + return false + }, + })) + fmt.Printf("Serving the web UI from %q.\n", dir) +} + func main() { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { @@ -144,6 +183,7 @@ func main() { humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) api.RegisterRoutes(humaApi, queries) + registerWebUI(e) port := os.Getenv("PORT") if port == "" {