diff --git a/README.md b/README.md index 51f9b5b..5026bc0 100644 --- a/README.md +++ b/README.md @@ -8,41 +8,45 @@ Full Circuit is a web application for exploring PC components and planning a cus ## Table of contents -- [Full Circuit](#full-circuit) - - [Table of contents](#table-of-contents) - - [Project status](#project-status) - - [Accessibility](#accessibility) - - [Sustainability](#sustainability) - - [Dataset](#dataset) - - [Database](#database) - - [Seed the database](#seed-the-database) - - [Backend](#backend) - - [What is set up on the VM](#what-is-set-up-on-the-vm) - - [Working with it](#working-with-it) - - [Run the backend locally](#run-the-backend-locally) - - [Current prototype](#current-prototype) - - [PC builder](#pc-builder) - - [Navigation and shared build state](#navigation-and-shared-build-state) - - [Published builds](#published-builds) - - [Planned functionality](#planned-functionality) - - [Use of AI](#use-of-ai) - - [Testing](#testing) - - [Run the prototype locally](#run-the-prototype-locally) - - [Working in this repository](#working-in-this-repository) - - [Formatting](#formatting) - - [Pull requests](#pull-requests) +- [Project status](#project-status) +- [Accessibility](#accessibility) +- [Sustainability](#sustainability) +- [Dataset](#dataset) +- [Database](#database) + - [Seed the database](#seed-the-database) +- [Backend](#backend) + - [What is set up on the VM](#what-is-set-up-on-the-vm) + - [Working with it](#working-with-it) + - [Run the backend locally](#run-the-backend-locally) + - [Abuse protection and request limits](#abuse-protection-and-request-limits) +- [Current prototype](#current-prototype) + - [PC builder](#pc-builder) + - [Navigation and shared build state](#navigation-and-shared-build-state) + - [Authentication decision and ownership](#authentication-decision-and-ownership) + - [Account builds](#account-builds) + - [Published builds](#published-builds) +- [Planned functionality](#planned-functionality) +- [Contributions](#contributions) +- [Use of AI](#use-of-ai) +- [Testing](#testing) +- [Run the prototype locally](#run-the-prototype-locally) +- [Working in this repository](#working-in-this-repository) ## Project status -The app displays a component catalog backed by PostgreSQL and GraphQL, with search, filtering, sorting, component details, and a load-more button. The frontend loads 40 components per request. The PC builder lets users select parts, view compatibility checks, and publish a build with a name and optional comment. Current selections are saved in the browser, while published builds are stored in the database and shown on the Community page. +The app displays a component catalog backed by PostgreSQL and GraphQL, with search, filtering, sorting, component details, and a load-more button. The frontend loads 40 components per request. The PC builder lets users select parts, view compatibility checks, and publish a build with a name and optional comment. Current selections are saved in the browser. Firebase authenticates users, and PostgreSQL stores their private and published builds. Users can reopen, update, publish, copy, and delete their own builds from My builds. Community lists published builds only. ## Accessibility -We have worked to make Full Circuit usable for as many people as possible. We use semantic HTML elements, labels for inputs and buttons, and alternative text for component images. Keyboard users can navigate using focusable controls with a visible focus outline. We have also made the layout responsive so it works on both mobile and desktop. We will continue checking accessibility as the project develops. +We have used semantic HTML, labeled controls, keyboard focus styles, alternative text, and responsive layouts to make Full Circuit easier to use. Our dialogs and status messages also provide information for assistive technologies. + +See [Accessibility and sustainability](docs/accessibility-and-sustainability.md#accessibility) for implementation examples, checks, and improvements we still need to make. ## Sustainability -We have tried to reduce unnecessary data transfer. The catalog loads a limited number of components at a time, and we only request the information needed to display them. More detailed information is fetched when users choose to view a component. We also use WebP images and a dark color scheme, which may help reduce energy use. These choices can reduce resource use while keeping the application easy to use. +We have used WebP images, browser caching, paginated requests, a short-lived catalog cache, and memoized cards to reduce repeated downloads and rendering work. We also keep the current build in local storage instead of saving every selection to the backend. + +See [Accessibility and sustainability](docs/accessibility-and-sustainability.md#sustainability) for our choices and their limitations. ## Dataset @@ -61,21 +65,21 @@ Each component has a unique ID, category, name, manufacturer, price in EUR, imag The repository includes 800 WebP images in `backend/data/part_images/`, named `.webp`. The backend serves these through its `/images/` endpoint. -The frontend no longer uses `frontend/src/data/parts.mock.json`. It retrieves components from PostgreSQL through the GraphQL API at `/api/graphql`, using `getParts` and `useParts`. The catalog requests 40 components at a time and supports search, filtering, sorting, and loading more results with cursor-based pagination. Full specifications are fetched only when a component's detail view is opened, reducing unnecessary data transfer. The backend supports extra specification filters, but the frontend does not use them yet. +The frontend no longer uses `frontend/src/data/parts.mock.json`. It retrieves components from PostgreSQL through the GraphQL API at `/api/graphql`, using `getParts` and `useParts`. The catalog requests 40 components at a time and supports search, filtering, sorting, and loading more results with cursor-based pagination. The catalog GraphQL query returns summaries without specifications. We fetch missing specifications when opening details or adding a component to the build, and reuse them while the card remains mounted. The backend supports extra specification filters, but the frontend does not use them yet. ## Database -The backend uses PostgreSQL, running directly on the virtual machine. It is only reachable from the VM itself (it listens on `localhost`). The dataset has 800 parts: 100 in each of the eight categories above. Specifications come from the [BuildCores OpenDB](https://github.com/buildcores/buildcores-open-db), prices are in EUR, and the images are 256x256 WebP files. +The backend uses PostgreSQL, running directly on the virtual machine. When working locally, we use a local database or connect to the VM database through an approved connection or SSH tunnel. Direct VM access requires NTNU network/VPN and PostgreSQL configured to accept the connection. The dataset has 800 parts: 100 in each of the eight categories above. Specifications come from the [BuildCores OpenDB](https://github.com/buildcores/buildcores-open-db), prices are in EUR, and the images are 256x256 WebP files. Files in `backend/`: - `data/parts.csv` – the dataset that is loaded into the database. - `data/part_images/` – one image per part, named `.webp`. -- `db/schema.sql` – creates the `parts` and `builds` tables and the parts indexes. -- `db/seed.sql` – inserts or updates components from `parts.csv`, preserving existing builds and components missing from the CSV. +- `db/schema.sql` – creates the `parts`, `users`, and `builds` tables and their indexes. +- `db/seed.sql` – empties `parts` and loads `parts.csv`. - `db/seed.sh` – runs `schema.sql` and `seed.sql`. -Each part is one row with the columns `id`, `category`, `name`, `manufacturer`, `price_eur`, `image_path`, and `specs` (details as `jsonb`). Published builds are saved in the `builds` table with a name, optional comment, date, and references to their selected parts. +Each part is one row with the columns `id`, `category`, `name`, `manufacturer`, `price_eur`, `image_path`, and `specs` (details as `jsonb`). Private and published builds are saved in the `builds` table with a name, optional comment, date, owner, visibility, and references to their selected parts. The `users` table links each Firebase UID to an internal user ID and optional display name. ### Seed the database @@ -85,18 +89,11 @@ To initialize or reseed the database, run from the repository root: ```bash export DATABASE_URL='postgresql://buildapc:change-me@localhost:5432/buildapc' -./backend/db/seed.sh +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f backend/db/schema.sql +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "\copy parts FROM 'backend/data/parts.csv' WITH (FORMAT csv, HEADER)" ``` -Initialization and seeding can be rerun with `./backend/db/seed.sh`. -Existing components are updated by ID, new components are inserted, and -published builds are preserved. Components missing from the CSV are retained. -The scripts run in one transaction, so failures roll back the entire operation. - -`IF NOT EXISTS` skips existing tables; it does not migrate their structure. -Future schema changes require separate migrations. - -Manually verified on local PostgreSQL 16.15: initial setup and repeated initialization succeeded with 800 components. Reseeding restored a modified component price while preserving an existing build and its CPU reference, with the component count remaining at 800. A deliberate SQL error after seeding rolled back the transaction and preserved the previous price. +Use these commands only for a new, empty database. We import directly because `seed.sh` runs `TRUNCATE parts`, which conflicts with the foreign keys from `builds`. The schema also creates `builds` without `IF NOT EXISTS`. We should update the seed scripts before using them as a repeatable setup or reset command. Changes to an existing database must be applied separately; editing `schema.sql` does not update it automatically. ## Backend @@ -137,7 +134,7 @@ sudo pm2 stop buildapc-backend # stop ### Run the backend locally -To run the backend on your computer, you need access to a PostgreSQL database. The VM database only accepts connections from the VM itself, so use a local database or set up an SSH tunnel. Set `CORS_ORIGIN=http://localhost:5173` in your `.env`: +To run the backend on your computer, you need access to a PostgreSQL database. Use your own local database, or ask for the correct VM connection details. If the VM only accepts local connections, use an SSH tunnel. Set `DATABASE_URL` to the database you are connecting to, and set `CORS_ORIGIN=http://localhost:5173` in your `.env`: ```bash cd backend/server @@ -145,34 +142,57 @@ npm ci npm run dev ``` +### Abuse protection and request limits + +Saving and publishing require login. We also limit write attempts per client IP to reduce abuse. The limits are set in `backend/server/src/config.ts`. + +| Limit | Value | When exceeded | +| ------------------------------------------------------ | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| Saving/publishing (`savePrivateBuild`, `publishBuild`) | 5 attempts per client IP in 10 minutes. | HTTP `429` with a `Retry-After` header and error code `RATE_LIMITED`. Nothing is written to the database. | +| Query depth | At most 8 levels of nested fields. | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Query size | At most 100 selected fields per operation (fragments and aliases are counted). | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Request body | At most 100 KB. | HTTP `413`, error code `PAYLOAD_TOO_LARGE`. | + +Authenticated save/publish attempts count, including invalid ones, and repeated mutation aliases count separately. Reading data is not rate limited, and introspection queries are not counted towards the depth and size limits. The counters are kept in the server's memory, so they reset when the backend restarts, and they are per backend process. + +The limit is per IP address, so the backend must see the visitor's real address. On the VM, Apache forwards requests to the backend, so `TRUST_PROXY=1` must be set in `backend/server/.env`. Without it, all visitors share the proxy's address and one limit. + ## Current prototype -The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog handles loading, error, and empty states. Catalog cards and the component detail dialog display data retrieved from the backend. The full-screen detail dialog uses separate mobile and desktop layouts, with an expandable specifications panel on desktop. +The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog supports search, filtering, sorting, and switching between list and grid layouts. It loads 40 components at a time through infinite scrolling or the **Load more** button, and handles loading, error, and empty states. Recent catalogue responses are cached for one minute. Previous cards remain visible while new results load, and unchanged cards can skip rendering through memoization. + +Catalog cards initially display summary data from the backend. Full specifications are fetched when needed for details or adding to a build, and cards retain specifications loaded for their detail view while mounted. The full-screen detail dialog has separate mobile and desktop layouts, both with expandable specifications. See [Sustainability](#sustainability) for the caching and data-flow details. ### PC builder -Open the builder through the navigation bar or by visiting `?page=builder`. Use **Add to build** on a catalog card or component detail page to select a part. +Open the builder through the navigation bar or by visiting `/project2/builder`. Use **Add to build** on a catalog card or component detail page to select a part. -- Each of the eight categories has one slot. Adding another component in the same category replaces the previous selection. +- Each of the eight categories has one slot. Adding another component in the same category opens a confirmation dialog, allowing users to keep the current component or replace it. +- A nonmodal notification confirms when a component has been added and offers **View build**, so we can continue browsing or return to the builder. - Empty slots have a **Choose** action that opens the category catalog. Selected slots show the component image, name, first three specifications, and price, with actions to change or delete the selection. -- The summary shows the selected component count, progress, and total price in EUR. Missing prices currently contribute zero to the total, so the displayed sum may be incomplete. -- **Clear build** removes all selections. +- The summary shows the selected component count, progress, and total price in EUR. Components without prices are left out of the sum, and we display an incomplete-total message. +- **Clear build** asks for confirmation before removing all selections. -Selections are stored as JSON under the `pc-builder` key in `localStorage` and restored when the app loads. This preserves the current build across refreshes and page navigation in the same browser and site. Localhost and the deployed site have separate storage. The compatibility panel checks selected parts and shows results such as compatible, incompatible, warning, or unknown. The checks may not cover every possible combination. Users can also publish their builds to the database, but private builds are not synchronized between browsers. +Selections and saved-build metadata are stored as JSON under `pc-builder-v2:` (or `pc-builder-v2:guest`) in `localStorage` and restored when the app loads. This preserves the current build across refreshes and page navigation in the same browser and site. Localhost and the deployed site have separate storage. The compatibility panel checks selected parts and shows results such as compatible, incompatible, warning, or unknown. The checks may not cover every possible combination. Signed-in users can save private builds or publish to Community, and retrieve them across sessions and devices through My builds. ### Navigation and shared build state -`frontend/src/App.tsx` renders the page based on URL query parameters: +`frontend/src/App.tsx` uses React Router to render pages based on URL paths: -| Page | URL relative to the app base | -| ------------------ | ------------------------------------------- | -| Home | No query parameters | -| Explore components | `?page=explore` | -| Component category | `?category=CPU` (or another category value) | -| PC builder | `?page=builder` | -| Community builds | `?page=community` | +| Page | Route within the app | +| ------------------ | --------------------------------------------- | +| Home | `/` | +| Explore components | `/explore` | +| Component category | `/explore/category/CPU` (or another category) | +| PC builder | `/builder` | +| Community builds | `/community` | +| Login/register | `/login` | +| My account/builds | `/account` | +| Reopen a build | `/builder?build=` | -The builder takes precedence if both `page=builder` and a category are supplied. Category URLs use the values defined in `frontend/src/types/categories.ts`, such as `CPU`, rather than display labels such as `Processors`. Navigation uses regular links or `window.location.href`, which reload the page; the build provider restores saved selections afterward. Home links use `import.meta.env.BASE_URL` to support deployment under `/project2/`. +`BrowserRouter` in `frontend/src/main.tsx` uses `import.meta.env.BASE_URL` as its base path. With the `/project2/` deployment base, the builder's full path is `/project2/builder`. Category URLs use the values defined in `frontend/src/types/categories.ts`, such as `CPU`, rather than display labels such as `Processors`. Unknown routes display a 404 message. Search, category filters, and sorting still use query parameters through `useCatalogParams`, so they survive refresh and browser back/forward navigation. We no longer use `?page=...` to select pages. + +Most navigation uses React Router links or `navigate()`, changing pages without a full browser reload. The feedback notification's **View build** action currently uses `window.location.href`, which reloads the page; the build provider then restores saved selections from local storage. `BuildProvider` wraps the app in `frontend/src/main.tsx`. Components use `useBuild()` from `frontend/src/context/BuildContext.ts` to access: @@ -181,24 +201,76 @@ The builder takes precedence if both `page=builder` and a category are supplied. - `removePart(category)`: remove a selection. - `resetBuild()`: clear the build. +We use `useSavedBuild()` for saved-build metadata and loading/saving actions. + Use `AddToBuildButton` for actions that add a component to this shared state. The generic `Button` remains available for other click handlers and form submissions. +### Authentication decision and ownership + +We evaluated three providers for our React, GraphQL, and existing PostgreSQL architecture: + +| Provider | Fit and decision | +| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Clerk](https://clerk.com/docs/guides/sessions/manual-jwt-verification) | Provides authentication UI and verifiable session tokens. Suitable, but we chose to keep the small login UI in our own React application. | +| [Firebase Authentication](https://firebase.google.com/docs/auth/admin/verify-id-tokens) | Supports Google and email/password sign-in with ID tokens our GraphQL backend can verify. Selected: it fits our existing backend without moving application data. | +| [Supabase Auth](https://supabase.com/docs/guides/auth/architecture) | Supports token-based authentication and integrates with Supabase PostgreSQL. Suitable, but its integrated database features provide less benefit while we retain our VM database. | + +Firebase manages accounts and passwords. The frontend sends an ID token with authenticated GraphQL requests; Firebase Admin verifies its signature, expiry, issuer, and project. Basic token verification uses Google's public signing keys and the public project ID; no service-account private key is needed. We do not perform token-revocation checks or Firebase administrative account operations. Firebase public web configuration is in `frontend/src/lib/firebase.ts`, and the backend project ID is in `backend/server/src/firebase.ts`. Private database credentials remain in the ignored backend `.env`. + +The backend finds or creates a PostgreSQL user by the verified Firebase UID. `authenticatedResolvers.ts` integrates ownership operations with the catalog resolvers and per-request part loader from `resolvers.ts`. Both private saves and publishing use the existing per-IP attempt limit. Ownership always comes from this authenticated context. `myBuild` and `myBuilds` return only owned builds; updates and deletion include both the build ID and owner ID in the SQL condition. Community only returns published builds. Email addresses and passwords are not stored in our PostgreSQL user table. + +Existing builds are preserved as published with `owner_id = NULL`; users cannot claim, update, or delete them. The VM upgrade was applied separately from `schema.sql`. On another existing database, run the following once (not `seed.sh`): + +```sql +BEGIN; +CREATE TABLE IF NOT EXISTS users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + firebase_uid varchar(128) NOT NULL UNIQUE, + display_name text +); +ALTER TABLE builds + ADD COLUMN IF NOT EXISTS owner_id bigint REFERENCES users(id), + ADD COLUMN IF NOT EXISTS is_published boolean NOT NULL DEFAULT true; +ALTER TABLE builds ALTER COLUMN is_published SET DEFAULT false; +CREATE INDEX IF NOT EXISTS builds_owner_id_idx ON builds(owner_id); +COMMIT; +``` + +The upgrade's initial `true` default preserves pre-existing public builds; subsequent new builds default to private. If the columns already exist, the upgrade preserves their values. New databases use `schema.sql`, whose default is private from the start. + +### Account builds + +Sign in using Google or email/password, then use the account icon to open My builds. The PC builder supports **Save privately** and **Publish build**. Private builds appear only in their owner's account; published builds also appear in Community. Each save creates a build until an existing build is opened or saved; subsequent saves update that build. Select **Save as new** in the save dialog to create a separate copy. + +Use **Open in builder** on an account build to restore its selected components, name, comment, and visibility. Account and login pages use `/project2/account` and `/project2/login`. Opening a saved build at `/project2/builder?build=` replaces the current browser draft immediately; it does not modify any build in the database until Save or Publish is used. Drafts and the active build ID are stored separately for each account in the browser and survive navigation and refresh. **Clear build** starts a new draft. Saving an opened published build privately removes it from Community. The GraphQL API checks ownership when reading, updating, or deleting account builds. The Delete action asks for confirmation and removes the build from PostgreSQL and Community. If that build is open in the browser, its selected parts remain as a new local draft. + ### Published builds Users can publish their current build from the PC builder by entering a name and an optional comment. Publishing saves the chosen parts in PostgreSQL through GraphQL. Empty builds cannot be published, and the page shows an error if publishing fails. -The Community page at `?page=community` lists published builds with their names, comments, and parts. Viewing the list does not change the visitor's current build. Individual build pages and shareable links are not available yet. +The Community page at `/project2/community` lists published builds with their names, comments, and parts. It displays up to 20 builds per page, with **Previous** and **Next** controls when available, and handles loading, error, and empty states. Viewing the list does not change the visitor's current build. Individual build pages and shareable links are not available yet. ## Planned functionality Features we still plan to add: -- A separate page and shareable link for each published build. -- Saving and reopening private builds across devices. +- Additional abuse protection beyond the existing per-IP publishing limit and GraphQL request limits. Authentication alone does not prevent publishing abuse. +- Account deletion and password-reset UI. We already support deleting individual builds. + +## Contributions + +We have summarized our contributions below based on the work recorded in the repository. We have worked across several areas, so these are examples rather than exclusive responsibilities. + +| Member | Contributions | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Kristian Gjertsen | Component cards and types, frontend GraphQL integration, publishing and Community pages, React Router navigation, responsive header, shared search/category controls, skeleton loading, and formatting setup. | +| Ludvig Zapffe | Dataset and PostgreSQL setup, catalog GraphQL API, deployment documentation, backend and compatibility tests, request/rate limits, build-parts batching, URL filter state, and UI/accessibility improvements. | +| Oscar Grande | Backend TypeScript setup, attribute filters, homepage and builder styling, keyboard/detail-view improvements, Firebase authentication, PostgreSQL users, and saving, reopening, updating, copying, and deleting owned builds with related tests. | +| Wilhelm Svensgaard Rolstad | Initial frontend setup, mobile details, search/filtering/pagination, view selection, compatibility checks, frontend testing, deferred specifications, catalog caching/memoization, image handling, and add/remove feedback. | ## Use of AI -The group used AI where we had less experience, as a discussion partner during development to explore solutions, understand unfamiliar tools and concepts, and help debug code. We also used it to help write documentation, and to help create issues and pull requests: we gave it our ideas and requirements, and it polished and formatted them. +We used AI where we had less experience, as a discussion partner during development to explore solutions, understand unfamiliar tools and concepts, and help debug code. We also used it to help write documentation, and to help create issues and pull requests: we gave it our ideas and requirements, and it polished and formatted them. AI suggestions were not used blindly. Some needed simplifying, and all were reviewed before being merged. @@ -212,9 +284,17 @@ npm run test See [frontend/src/test/README.md](frontend/src/test/README.md) for the test setup and covered scenarios. +We also have backend tests for catalog queries, input validation, request limits, batching, and build ownership. Run them from `backend/server/`: + +```bash +npm test +``` + +This runs the Vitest suites, builds the backend, and runs the Node ownership tests. We use mocked database responses and isolated fixtures, so the tests do not require the VM or real Firebase accounts. We still need manual checks for deployed login and database access. + ## Run the prototype locally -You need Node.js and npm installed. From the repository root, run: +You need Node.js and npm installed. Use Node 24 or 26; the current React Router and Vitest dependencies do not support Node 20. From the repository root, run: ```bash cd frontend @@ -222,7 +302,7 @@ npm ci npm run dev ``` -Open the local URL printed by Vite in the terminal. +Open the local URL printed by Vite, normally `http://localhost:5173/project2/`. The backend must also be running and connected to a PostgreSQL database. In a second terminal: @@ -238,7 +318,7 @@ npm run dev The catalog requests 40 parts at a time, passing search, filter, sort, and pagination options to the API. Vite forwards `/api/*` to `http://localhost:3001/*` during development, including images. -On the VM, the web server must forward `/api/` requests to the backend on port 3001. The Vite development proxy is not included in the production build. The frontend is served under `/project2/`, but `/api/` is at the domain root. +On the VM, the web server must forward `/api/` requests to the backend on port 3001. The Vite development proxy is not included in the production build. The frontend is served under `/project2/`, but `/api/` is at the domain root. Apache must also serve `index.html` for React Router paths so direct links and refresh work. We need HTTPS for deployed login traffic. Available commands, run from `frontend/`: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 1995039..65bb812 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -53,9 +53,16 @@ CREATE INDEX IF NOT EXISTS parts_cooler_height_idx WHERE category = 'CPUCooler'; +CREATE TABLE IF NOT EXISTS users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + firebase_uid varchar(128) NOT NULL UNIQUE, + display_name text +); + /* Saved builds */ CREATE TABLE IF NOT EXISTS builds ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tittel varchar(50) NOT NULL CHECK (length(trim(tittel)) > 0), created_at timestamptz NOT NULL DEFAULT now(), @@ -69,4 +76,6 @@ CREATE TABLE IF NOT EXISTS builds ( psu_id uuid REFERENCES parts(id), case_id uuid REFERENCES parts(id), cooler_id uuid REFERENCES parts(id) -); \ No newline at end of file +); +CREATE INDEX IF NOT EXISTS builds_owner_id_idx + ON builds(owner_id); \ No newline at end of file diff --git a/backend/server/.env.example b/backend/server/.env.example index c2a0aa4..2e7f10d 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -8,9 +8,13 @@ PORT=3001 # Comma-separated list of browser origins allowed to read API responses, or * for any. # On the VM this is the address the frontend is served from. CORS_ORIGIN=http://it2810-15.idi.ntnu.no +# Number of reverse proxies in front of the backend. On the VM, Apache forwards requests to the +# backend, so this must be 1 for the rate limit to count each visitor separately by IP address. +TRUST_PROXY=1 # --- Running backend and frontend locally, with the database on the VM --- # Needs NTNU network or VPN, and the VM's Postgres must accept outside connections. -# Use these two lines in your own .env instead of the ones above: +# Use these three lines in your own .env instead of the ones above (there is no proxy locally): # DATABASE_URL=postgresql://buildapc:@it2810-15.idi.ntnu.no:5432/buildapc # CORS_ORIGIN=http://localhost:5173 +# TRUST_PROXY=0 diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index f14d85a..21bf34f 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -12,10 +12,13 @@ "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^18.0.7", "express": "^5.3.0", + "firebase-admin": "^14.5.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -28,7 +31,8 @@ "prettier": "3.9.10", "tsx": "^4.23.15", "typescript": "~6.0.0", - "typescript-eslint": "^8.71.2" + "typescript-eslint": "^8.71.2", + "vitest": "^5.0.5" } }, "node_modules/@apollo/cache-control-types": { @@ -305,6 +309,43 @@ "keyv": "^5.6.0" } }, + "node_modules/@emnapi/core": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-beta.2.tgz", + "integrity": "sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "2.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-beta.2.tgz", + "integrity": "sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz", + "integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -875,6 +916,226 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.5.tgz", + "integrity": "sha512-y8MTAeBlEQUNVnskmZQwpseZN3EgCksQNOULlfxWY6o6VEqh01+oExMgbM7gN6YLQ8pNDYeweNPKJRt8jQm2ZQ==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz", + "integrity": "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", + "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.2" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz", + "integrity": "sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.5.tgz", + "integrity": "sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.6.tgz", + "integrity": "sha512-nPktjjCWA9TSJUc0KtStBRugI6AF1b53qOlDvXkHtueIBt1sOhPzCT7S2gtxrs07S9jGA2o7J5faEa/PFAXomg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.8.tgz", + "integrity": "sha512-wCMI122co2+rbldcZALIgmFoVn0Pj+PAuCjo0zvppMaPm/ksmK9twpmGCrmbUVjqe6xRs0YkCrKlblNujG2cpQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/database": "1.1.6", + "@firebase/database-types": "1.0.22", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + }, + "peerDependenciesMeta": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { + "optional": true + } + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.22.tgz", + "integrity": "sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.6", + "@firebase/util": "1.15.3" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.2.tgz", + "integrity": "sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.3.tgz", + "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-9.4.1.tgz", + "integrity": "sha512-arD6dY7H8G1647OqxHmgxb3m1IYAAoVDm9ev/MW1meQRl1XuaNP8j/aGT5HPHTeaV/8AHZVGeXwOGTovWvIKIA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/firestore-api": "^0.7.0", + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^6.12.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore-api": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore-api/-/firestore-api-0.7.1.tgz", + "integrity": "sha512-CM6Xm8P/eU59AgH94whcJxA4jaUUgH8Lj4Ki425yFlICEsOaw7Sc6XuezvrZGoXec+W5iVsQgPrZHnwF5g98Kg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "google-gax": "^6.0.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@google-cloud/paginator": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-7.2.0.tgz", + "integrity": "sha512-l+x6YObHGTuqBCMvC0SXo3Cnyqg4FkWWKYRptf46PACHJ5GAUuhsd2b3wphgGrg1ybs/LAb+q5MnUTuG/3+v7w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-6.2.1.tgz", + "integrity": "sha512-PV9oy7VpQRXLuqKuU5RSOHFQyWgvw2sT9MoTGykja9j6c9pv5+ttKTJiFsccu500YYIGiz8JCOsb60PfKmiV/w==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-6.2.1.tgz", + "integrity": "sha512-/+RvLJ5u+WhGay6Z3aY6OApDQmNqq1427Mmt7Eov9kYp69gdUiGSwFnygeTxfz69XBtYZLAnWikGMIoFXtKt+A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/storage": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-8.4.0.tgz", + "integrity": "sha512-M3kA6UA2Tjp5aznCrgKcm9CzOjiQyRMnquqalqXAeS5wvkqqZxN8wpNjjHafOSYs6lQ82U7f2i8we4eTfIrpkA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^7.0.1", + "@google-cloud/projectify": "^6.0.1", + "@google-cloud/promisify": "^6.0.1", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^7.1.4", + "google-auth-library": "^11.0.0", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^9.0.1", + "teeny-request": "^11.0.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@graphql-tools/merge": { "version": "9.2.7", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.7.tgz", @@ -935,6 +1196,46 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.7.tgz", + "integrity": "sha512-W4W/fcBMGRVtG6dW2c7cw2mnpS3hvbBd+oQXjh2iYFTZDk1JJmcNqkM5KSaPubQpamlUUurrlb7G1s9NJeERmQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1001,6 +1302,52 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.3.tgz", + "integrity": "sha512-lhTtjtoEArWBjismtfS761S9Hlyn+nIZqOZVYoZ1Xn82PMNMVnk+jj/NhC4fAXGTs7ydONcJiR5vyQBTMDu27g==", + "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/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -1025,6 +1372,63 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1088,47 +1492,393 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.19.tgz", + "integrity": "sha512-e+Ebe0WT+9tbXmNKPIiC8YrWAHYwug3m6i4p3rZGGulfZri1jtI4HslT+OZnFf/OIHowWF71DTlHwXBP2SiA7A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.19.tgz", + "integrity": "sha512-hOombrTJ+szt2ciEzS7oCA78lMPBgF5UMQWCJIyNfnrI7GLl8HYoFqCnpL2gcPOaXMqryGCU5ExdQKyDDcC+yA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.19.tgz", + "integrity": "sha512-XoMA2pwCKGTQzUjPgtRwkedH4WmXhdIPKT6l6oOxd1PUbNodGI6DcBekgN7EC//+x3pfBYX/qL1gNmZAqivw5g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.19.tgz", + "integrity": "sha512-zo8EsSSwMZCY/e+fRnGaX02YGgvJD0yvhNi/9hpGPA2MJ9ZF8dPCw75UGfpI6OpQ/myR/dIYmogRvqiZD0TrXg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.19.tgz", + "integrity": "sha512-O8ISAOfI1nj7AuBPd30FSF47z7f7lzlk5poQVGgiikvQuGEBFYvW8ki+UEnT+YJPnNGjQzn304klt4O9FUE7mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.19.tgz", + "integrity": "sha512-/lYCba5NO0yq4S0jGMH8QTBtkvQxoXjoQWzF5BcWp9MjB9jxVRD1GQDNofkVjcOj30DWyhLIN/4Oe60JKzaONw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.19.tgz", + "integrity": "sha512-pm/apFMBnSbFb3O6nG5pHO1iqHhZrjGDFEjSm+ot9J17XXPvH9L90y5h+jgfHCE+Qdlsvy8qKEcU4VKxgjD+vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.19.tgz", + "integrity": "sha512-K1SmQp3FFichn8oqrNIwvFoZ5wcvjC7goBzpaMNvLf9FZc+QB/QvLmnpJZgAAGHiD980tP8FZZnXk2K94rvlxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.19.tgz", + "integrity": "sha512-I0xwD57jP/3A7+5UPZin2w8Y7XHEmEM5zqpYZOcQ45MlNW6tUJUziymV3yRr7rL4vwyfEcRsRksC4a7Kc08p5Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.19.tgz", + "integrity": "sha512-BrtEHT24SNXj9HiXqy+s/kXiSiQGt6EKCPUlRirAoIioVxKGnbXBUEkbZASAei7iZJB0/Ynm+0JP00Ipx7AnSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.19.tgz", + "integrity": "sha512-rm75/9DkHKeU2WnA8w4X94WGYgLlDCFCfkQC+4bguVJmC/jvDRv2B+2wLOYhyE0fVuv7//Ckk39OoOufyCu76Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.19.tgz", + "integrity": "sha512-irh/4+iYFM4GAzpluDErgjz89bj6NDtqqMAG/6O1LG38Ed3NpyebzCpcVNkFW8uhy+/3/BocUvnXDR33CXWe9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.19.tgz", + "integrity": "sha512-+KWTWNoPt+CfSq94CiceWu3yPE+QzkaG+phPyz9G60tmZLwfQkcA4AeksafzvJAw4pvg1mcd5a1TkUqVv0s4JA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.19.tgz", + "integrity": "sha512-7EH+skOsl5qpWnK0vzamgJFhOv1cwxQpsxm8tI883K9fur5c27p8q87fzEO6BbBPabA2/+s/zR63/pLc137iOg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "2.0.0-beta.2", + "@emnapi/runtime": "2.0.0-beta.2", + "@napi-rs/wasm-runtime": "1.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.19.tgz", + "integrity": "sha512-3Q1Yg1Q7tEvBWoZf5rFvG/GKZQPgwfru+p4wBajDfJFBs0SHCpL++dfTjW9gyAKd1T7ZpIYvdaUax7Pa/IhFVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.19.tgz", + "integrity": "sha512-y/sqRRc6d7hECzOFc5n3LSeqctmIp9mMjY1Kq4s14uLPd/LZ4z7i1zrW6fMtp2mPeuQIC4e6ULMcwQzMomqQag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.1.tgz", + "integrity": "sha512-glFIJDvLNDtJhT9qrMFWLPbvS+skxgt4L0K8Ei2YtnhPysAJnsI7nyxSJxKj4ADeKX0OLjC4ntRkwpLOoS3rwA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "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/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" @@ -1172,17 +1922,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -1465,6 +2230,44 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@whatwg-node/promise-helpers": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", @@ -1477,6 +2280,19 @@ "node": ">=22.15.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1513,6 +2329,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1530,12 +2355,61 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "dependencies": { + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "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/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { "retry": "0.13.1" } }, @@ -1564,19 +2438,48 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -1614,6 +2517,12 @@ "node": "20 || >=22" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1684,6 +2593,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, "node_modules/content-disposition": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", @@ -1768,6 +2722,21 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1818,6 +2787,17 @@ "node": ">= 0.8" } }, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "18.0.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.7.tgz", @@ -1847,12 +2827,41 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1862,6 +2871,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1880,6 +2899,13 @@ "node": ">= 0.4" } }, + "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/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1934,6 +2960,16 @@ "@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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2114,6 +3150,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2133,6 +3179,26 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "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/express": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", @@ -2189,11 +3255,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "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-json-stable-stringify": { @@ -2210,6 +3281,59 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.2.tgz", + "integrity": "sha512-OX91VSlShLPDgRZV7tk/YQ4l8tT75QziF5Ixg8g4PFbCVPjuTWwN+yQfAxLJ0dufJpprdOd6HlM74Jhd39TcBQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.1.tgz", + "integrity": "sha512-wR8dO/fx+z8C5exuem1e7BKOWvjsuzvjS/tvA/Xu3Z1zQqJGcXrPzXbcAiaMgp2KjreEROcTkmnnisrcmJ2hYQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^3.1.0", + "fast-xml-builder": "^1.3.2", + "is-unsafe": "^2.0.3", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.5.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2228,6 +3352,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", @@ -2276,6 +3423,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase-admin": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-14.5.1.tgz", + "integrity": "sha512-3NSWRREeFCET2tyiOd0ENvRFdagi9voBRGCiNbGSIRbwuMJh2Eh/dcmItNWd82rcqd+fyW5/77/oZOZxC27/sA==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.1.4", + "@firebase/database-types": "^1.0.20", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^11.1.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^4.0.1" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^9.1.0", + "@google-cloud/storage": "^8.1.0" + } + }, "node_modules/flat-cache": { "version": "6.1.23", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", @@ -2310,6 +3479,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2352,6 +3533,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.2.3.tgz", + "integrity": "sha512-d2OI/dJJ3IIaeOz3c9jvr5j5G5beI5fQzbgilNiWhDKGwvJN9o10Qub70NkbF6PNJSUoNKZ77JKYGz0Fs4+9Lg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^8.3.1", + "google-logging-utils": "^2.1.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-8.3.3.tgz", + "integrity": "sha512-zqPbbtW5bAOZ8BRjS7bxj5VG7PBY5PwKRxpku9NplLqAPmYMoXExL1KOdaK5fHvwRETu3bPplLEsFGDdUUlrpw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "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==", + "license": "ISC", + "optional": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2402,6 +3643,69 @@ "node": ">=10.13.0" } }, + "node_modules/google-auth-library": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.3.4.tgz", + "integrity": "sha512-RaYCDoeYIIFTRgH8hesSxqLrmtC2l+DB3cSCS/SN1TPGjI2VpPdJmF3bfRLUn8VvP/4I2Af9fsktw2f9SvVSVg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^8.3.1", + "gcp-metadata": "^9.2.1", + "google-logging-utils": "^2.1.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-8.3.3.tgz", + "integrity": "sha512-zqPbbtW5bAOZ8BRjS7bxj5VG7PBY5PwKRxpku9NplLqAPmYMoXExL1KOdaK5fHvwRETu3bPplLEsFGDdUUlrpw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-6.17.0.tgz", + "integrity": "sha512-qitOd0t2mqRFaP2KGRVL+/refKafCVwbXQVodICQX93x/WbKyopMz0r5mdBsEjwctQ22ITZyrQeabRddpFeHXQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "@opentelemetry/api": "^1.9.0", + "duplexify": "^4.1.3", + "google-auth-library": "^11.3.1", + "google-logging-utils": "^2.1.0", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^4.1.0", + "protobufjs": "^7.5.4", + "retry-request": "^9.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.2.0.tgz", + "integrity": "sha512-Q6HuSEyoaxi0AgqUrOYb2gc77fDl4QugRoHpreI8VpW/VzPsoKyxjL8tGKLmZpWlvC1uVBXM85tykoQpCGBF5g==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2494,6 +3798,23 @@ "dev": true, "license": "MIT" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2514,6 +3835,39 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2587,6 +3941,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2621,6 +3985,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unsafe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz", + "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -2634,6 +4011,24 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.17", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.17.tgz", + "integrity": "sha512-wOuK9V1yujQFH5S0ZWmLcDZN7+IKh9bUCC2ZPCf2HDlol3WWHy+PPyNjEI75FXRLMp3vDKQcupP59xhVU95hcg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2646,32 +4041,382 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz", + "integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-cache": "^11.0.0", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/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", + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "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" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "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, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "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": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "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" + ], + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2688,6 +4433,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -2722,6 +4522,26 @@ "node": "20 || >=22" } }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2756,6 +4576,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2782,16 +4615,17 @@ } }, "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.8.tgz", + "integrity": "sha512-nFQuYSTVrGvysVBps5Ih+0BdnYFN/Cj3C816r6+p7yK+FHrVLGE4O4N9AFwykrlNlvM2HYt+Rad1Uiro6h88Ig==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2803,6 +4637,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2839,6 +4693,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2848,6 +4740,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2860,6 +4762,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2903,7 +4819,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -2950,6 +4866,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3059,6 +4991,14 @@ "split2": "^4.1.0" } }, + "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", + "peer": true + }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", @@ -3081,6 +5021,36 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -3146,6 +5116,50 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proto3-json-serializer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-4.1.0.tgz", + "integrity": "sha512-Z0Blk2BTZTPp9ZXOWnRG/P3bkEs53FtMv59sl4xU44xcDdYhKlVO/AqAHTYXAlW9HdislqQxjSornP3457wkag==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.5.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -3194,9 +5208,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -3215,35 +5229,115 @@ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/rate-limiter-flexible": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.4.1.tgz", + "integrity": "sha512-rT3HoDh6M+xhmmFKoqwMfIVO+Qw5GpoMZbvt9tgVLAj2H2NsHiEAtEIdZ72rKMXutQ/TntxrYc/BkzjizdrcGw==", + "license": "ISC" + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" } }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/retry-request": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-9.1.0.tgz", + "integrity": "sha512-FILyVbis+DTo3KRbtKceqpj1595wIF+XQFW/+AVOBdCQmxwNDgcNXXr2FNM0Ej2qD7zVXbJGfH7lo4jB0wwhKA==", "license": "MIT", + "optional": true, "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "extend": "^3.0.2" }, "engines": { - "node": ">= 0.10" + "node": ">=22" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "node_modules/rolldown": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.19.tgz", + "integrity": "sha512-QIiV1J9d0pzN4jeeTH4sjPy0AZ6cAy2cG0ExeoE1+U8Rvj+hR3gYkokx0YbeUNGlZqiQJJ2utcETe0oKXwzsuA==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">= 4" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.19", + "@rolldown/binding-android-arm64": "1.2.19", + "@rolldown/binding-darwin-arm64": "1.2.19", + "@rolldown/binding-darwin-x64": "1.2.19", + "@rolldown/binding-freebsd-x64": "1.2.19", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.19", + "@rolldown/binding-linux-arm64-gnu": "1.2.19", + "@rolldown/binding-linux-arm64-musl": "1.2.19", + "@rolldown/binding-linux-ppc64-gnu": "1.2.19", + "@rolldown/binding-linux-s390x-gnu": "1.2.19", + "@rolldown/binding-linux-x64-gnu": "1.2.19", + "@rolldown/binding-linux-x64-musl": "1.2.19", + "@rolldown/binding-openharmony-arm64": "1.2.19", + "@rolldown/binding-wasm32-wasi": "1.2.19", + "@rolldown/binding-win32-arm64-msvc": "1.2.19", + "@rolldown/binding-win32-x64-msvc": "1.2.19" } }, "node_modules/router": { @@ -3292,7 +5386,6 @@ "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" @@ -3484,6 +5577,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -3502,6 +5606,127 @@ "node": ">= 0.8" } }, + "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/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-11.1.1.tgz", + "integrity": "sha512-I5OLOkPN3QDN2l1H/eGueuKaq32CdAcXt91WOF/bOfadtZD1WQ8/Fk28WsqDcGjIyhAW/GQDagYhVEjhuZUFyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "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", @@ -3680,7 +5905,6 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3702,6 +5926,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3711,6 +5942,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.8.tgz", + "integrity": "sha512-LzOn3h9rG/0vhLdXrV4yJ4QFA9eEoqCl+Yx+kGJd7aB9w6nfNgVHKSYwLPZUHrz4ZGa+t+kXjV9itvcHDLQouA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.18", + "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 || ^0.8.0", + "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/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "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.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "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/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -3757,6 +6182,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3767,12 +6205,46 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3782,11 +6254,50 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/backend/server/package.json b/backend/server/package.json index 404e03c..ea01ffc 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -7,7 +7,8 @@ "type": "module", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run src/test && npm run test:ownership", + "test:ownership": "npm run build && node --test test/*.test.mjs", "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", @@ -26,15 +27,22 @@ "prettier": "3.9.10", "tsx": "^4.23.15", "typescript": "~6.0.0", - "typescript-eslint": "^8.71.2" + "typescript-eslint": "^8.71.2", + "vitest": "^5.0.5" }, "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^18.0.7", "express": "^5.3.0", + "firebase-admin": "^14.5.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" + }, + "overrides": { + "minimatch": "^11.0.1" } } diff --git a/backend/server/src/auth.ts b/backend/server/src/auth.ts new file mode 100644 index 0000000..ccfc6b1 --- /dev/null +++ b/backend/server/src/auth.ts @@ -0,0 +1,39 @@ +import { GraphQLError } from "graphql"; +import { firebaseAuth } from "./firebase.js"; +import { findOrCreateUser } from "./users.js"; +import type { DatabaseUser } from "./users.js"; + +export type AuthContext = { + uid: string | null; + user: DatabaseUser | null; +}; + +export async function createAuthContext( + authorization?: string, +): Promise { + if (!authorization) { + return { uid: null, user: null }; + } + + const match = /^Bearer (\S+)$/i.exec(authorization); + + if (!match) { + throw new GraphQLError("Invalid authorization header", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + // Keep database errors separate from authentication errors. + const token = await firebaseAuth.verifyIdToken(match[1]!).catch(() => { + throw new GraphQLError("Invalid or expired login token", { + extensions: { code: "UNAUTHENTICATED" }, + }); + }); + + const displayName = + typeof token.name === "string" ? token.name : null; + + const user = await findOrCreateUser(token.uid, displayName); + + return { uid: token.uid, user }; +} \ No newline at end of file diff --git a/backend/server/src/authenticatedResolvers.ts b/backend/server/src/authenticatedResolvers.ts new file mode 100644 index 0000000..2775d2c --- /dev/null +++ b/backend/server/src/authenticatedResolvers.ts @@ -0,0 +1,286 @@ +import { GraphQLError } from "graphql"; +import { pool } from "./db.js"; +import type { AuthContext } from "./auth.js"; +import { consumePublishAttempt } from "./rateLimit.js"; +import { + resolvers as catalogResolvers, + type Context as CatalogContext, +} from "./resolvers.js"; + +export type Context = CatalogContext & AuthContext; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function createBuild( + _: unknown, + { + input, + }: { + input: { + id?: string | null; + tittel: string; + comment?: string | null; + partIds: string[]; + }; + }, + context: Context, + isPublished: boolean, +) { + if (!context.user) { + throw new GraphQLError( + isPublished ? "Sign in to publish a build." : "Sign in to save a build.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + await consumePublishAttempt(context.ip); + const tittel = input.tittel.trim(); + const comment = input.comment?.trim() || null; + const invalid = (message: string) => + new GraphQLError(message, { + extensions: { code: "BAD_USER_INPUT" }, + }); + if ( + input.id != null && + (!/^[1-9]\d*$/.test(input.id) || BigInt(input.id) > 9223372036854775807n) + ) { + throw invalid("Invalid build ID."); + } + if (!tittel || [...tittel].length > 50) + throw invalid("Enter a title of 1–50 characters."); + if (comment && [...comment].length > 200) + throw invalid("Comment must be at most 200 characters."); + if ( + input.partIds.length < 1 || + input.partIds.length > 8 || + input.partIds.some((id) => !UUID_RE.test(id)) + ) { + throw invalid("Choose between 1 and 8 valid components."); + } + const { rows: selected } = await pool.query<{ id: string; category: string }>( + "SELECT id, category FROM parts WHERE id = ANY($1::uuid[])", + [input.partIds], + ); + const categories = [ + "CPU", + "GPU", + "RAM", + "Storage", + "Motherboard", + "PSU", + "PCCase", + "CPUCooler", + ]; + if ( + selected.length !== input.partIds.length || + new Set(selected.map((part) => part.category)).size !== selected.length || + selected.some((part) => !categories.includes(part.category)) + ) { + throw invalid("Choose existing components, with only one per category."); + } + const ids = categories.map( + (category) => + selected.find((part) => part.category === category)?.id ?? null, + ); + const { rows } = await pool.query( + input.id != null + ? `UPDATE builds SET + tittel = $1, comment = $2, + cpu_id = $3, gpu_id = $4, ram_id = $5, storage_id = $6, + motherboard_id = $7, psu_id = $8, case_id = $9, cooler_id = $10, + is_published = $12 + WHERE id = $13 AND owner_id = $11 + RETURNING *` + : `INSERT INTO builds ( + tittel, comment, + cpu_id, gpu_id, ram_id, storage_id, + motherboard_id, psu_id, case_id, cooler_id, + owner_id, is_published + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12 + ) + RETURNING *`, + [ + tittel, + comment, + ...ids, + context.user.id, + isPublished, + ...(input.id != null ? [input.id] : []), + ], + ); + if (!rows[0]) { + throw new GraphQLError("Build not found or not owned by you.", { + extensions: { code: "NOT_FOUND" }, + }); + } + return rows[0]; +} + +// Keep dev's catalog resolvers and loader; override only operations requiring ownership. +const accountResolvers = { + Mutation: { + deleteBuild: async ( + _: unknown, + { id }: { id: string }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError("Sign in to delete a build.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + if (!/^[1-9]\d*$/.test(id) || BigInt(id) > 9223372036854775807n) { + throw new GraphQLError("Invalid build ID.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + const { rowCount } = await pool.query( + "DELETE FROM builds WHERE id = $1 AND owner_id = $2", + [id, context.user.id], + ); + if (!rowCount) { + throw new GraphQLError("Build not found or not owned by you.", { + extensions: { code: "NOT_FOUND" }, + }); + } + return true; + }, + publishBuild: ( + _: unknown, + args: Parameters[1], + context: Context, + ) => createBuild(_, args, context, true), + savePrivateBuild: ( + _: unknown, + args: Parameters[1], + context: Context, + ) => createBuild(_, args, context, false), + }, + Query: { + build: async (_: unknown, { id }: { id: string }) => { + if (!/^[1-9]\d{0,17}$/.test(id)) { + throw new GraphQLError("Invalid build ID", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + const { rows } = await pool.query( + "SELECT * FROM builds WHERE id = $1 AND is_published = true", + [id], + ); + return rows[0] ?? null; + }, + myBuild: async (_: unknown, { id }: { id: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError("Sign in to open a saved build.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + if (!/^[1-9]\d*$/.test(id) || BigInt(id) > 9223372036854775807n) + return null; + const { rows } = await pool.query( + "SELECT * FROM builds WHERE id = $1 AND owner_id = $2", + [id, context.user.id], + ); + return rows[0] ?? null; + }, + myBuilds: async ( + _: unknown, + { + limit = 21, + offset = 0, + }: { + limit?: number | null; + offset?: number | null; + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError("Sign in to view your builds.", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + if ( + limit == null || + offset == null || + limit < 1 || + limit > 100 || + offset < 0 + ) { + throw new GraphQLError("Invalid page size or offset", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const { rows } = await pool.query( + `SELECT * FROM builds + WHERE owner_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3`, + [context.user.id, limit, offset], + ); + + return rows; + }, + me: (_: unknown, __: unknown, context: AuthContext) => { + if (!context.user) return null; + + return { + id: context.user.id, + displayName: context.user.display_name, + }; + }, + builds: async ( + _: unknown, + { + limit = 20, + offset = 0, + }: { limit?: number | null; offset?: number | null }, + ) => { + if ( + limit == null || + offset == null || + limit < 1 || + limit > 100 || + offset < 0 + ) { + throw new GraphQLError("Invalid page size or offset", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + const { rows } = await pool.query( + `SELECT * FROM builds + WHERE is_published = true + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + return rows; + }, + }, + Build: { + isPublished: (row: { is_published: boolean }) => row.is_published, + }, +}; + +export const resolvers: Omit< + typeof catalogResolvers, + "Mutation" | "Query" | "Build" +> & { + Mutation: typeof accountResolvers.Mutation; + Query: Omit< + typeof catalogResolvers.Query, + keyof typeof accountResolvers.Query + > & + typeof accountResolvers.Query; + Build: typeof catalogResolvers.Build & typeof accountResolvers.Build; +} = { + ...catalogResolvers, + ...accountResolvers, + Query: { ...catalogResolvers.Query, ...accountResolvers.Query }, + Build: { ...catalogResolvers.Build, ...accountResolvers.Build }, +}; diff --git a/backend/server/src/config.ts b/backend/server/src/config.ts index 3527484..2d7d46d 100644 --- a/backend/server/src/config.ts +++ b/backend/server/src/config.ts @@ -18,4 +18,15 @@ export const config = { corsOrigins: (process.env["CORS_ORIGIN"] ?? "*") .split(",") .map((origin) => origin.trim()), + /** Number of reverse proxies (for example Apache) in front of the server. 0 means none. */ + trustProxy: Number(process.env["TRUST_PROXY"] ?? 0), + limits: { + /** Build publishing: at most this many attempts per client IP in each time window. */ + publishMax: 5, + publishWindowSeconds: 600, + /** GraphQL request limits. */ + maxBodySize: "100kb", + maxQueryDepth: 8, + maxQueryFields: 100, + }, }; diff --git a/backend/server/src/firebase.ts b/backend/server/src/firebase.ts new file mode 100644 index 0000000..09002db --- /dev/null +++ b/backend/server/src/firebase.ts @@ -0,0 +1,9 @@ +import "dotenv/config"; +import { initializeApp } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; + +const projectId = "full-circuit"; + +const app = initializeApp({ projectId }); + +export const firebaseAuth = getAuth(app); \ No newline at end of file diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 52eee5f..a2b86a6 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -5,11 +5,25 @@ import express from "express"; import path from "node:path"; import { config } from "./config.js"; import { pool } from "./db.js"; -import { resolvers } from "./resolvers.js"; +import { limitOperationSize } from "./limits.js"; +import { createBuildPartsLoader } from "./resolvers.js"; +import { resolvers, type Context } from "./authenticatedResolvers.js"; import { typeDefs } from "./schema.js"; +import { createAuthContext } from "./auth.js"; const app = express(); -const apollo = new ApolloServer({ typeDefs, resolvers }); +// Behind a reverse proxy, trust its forwarded address so each visitor is rate limited by their own IP. +app.set("trust proxy", config.trustProxy); +const apollo = new ApolloServer({ + typeDefs, + resolvers, + validationRules: [ + limitOperationSize({ + maxDepth: config.limits.maxQueryDepth, + maxFields: config.limits.maxQueryFields, + }), + ], +}); await apollo.start(); app.use( @@ -32,7 +46,37 @@ app.get("/health", async (_req, res) => { } }); -app.use("/graphql", express.json(), expressMiddleware(apollo)); +app.use( + "/graphql", + express.json({ limit: config.limits.maxBodySize }), + expressMiddleware(apollo, { + context: async ({ req }) => ({ + ...(await createAuthContext(req.headers.authorization)), + ip: req.ip ?? "unknown", + buildParts: createBuildPartsLoader(), + }), + }), +); + +// Oversized request bodies are rejected by express.json before they reach GraphQL. +app.use( + ( + error: { type?: string }, + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (error.type !== "entity.too.large") return next(error); + res.status(413).json({ + errors: [ + { + message: "Request body too large", + extensions: { code: "PAYLOAD_TOO_LARGE" }, + }, + ], + }); + }, +); app.listen(config.port, () => { console.log( diff --git a/backend/server/src/limits.ts b/backend/server/src/limits.ts new file mode 100644 index 0000000..afcf913 --- /dev/null +++ b/backend/server/src/limits.ts @@ -0,0 +1,66 @@ +import { + GraphQLError, + Kind, + type SelectionSetNode, + type ValidationRule, +} from "graphql"; + +interface Limits { + maxDepth: number; + maxFields: number; +} + +/** + * Validation rule that rejects operations that are nested too deeply or select too many + * fields. Fragments are expanded, so they cannot be used to hide the real size, and + * introspection fields (__schema, __type) are not counted. + */ +export function limitOperationSize({ + maxDepth, + maxFields, +}: Limits): ValidationRule { + return (context) => ({ + OperationDefinition(operation) { + let fields = 0; + const spreading = new Set(); + + // Returns an error message as soon as a limit is passed, so huge operations are cheap to reject. + function check( + selectionSet: SelectionSetNode, + depth: number, + ): string | null { + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.name.value.startsWith("__")) continue; + if (depth > maxDepth) + return `Query is nested too deeply (maximum depth is ${maxDepth}).`; + if (++fields > maxFields) + return `Query selects too many fields (maximum is ${maxFields}).`; + if (selection.selectionSet) { + const error = check(selection.selectionSet, depth + 1); + if (error) return error; + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const error = check(selection.selectionSet, depth); + if (error) return error; + } else { + const name = selection.name.value; + const fragment = context.getFragment(name); + // Fragment cycles are reported by another rule; just do not loop here. + if (!fragment || spreading.has(name)) continue; + spreading.add(name); + const error = check(fragment.selectionSet, depth); + spreading.delete(name); + if (error) return error; + } + } + return null; + } + + const message = check(operation.selectionSet, 1); + if (message) { + context.reportError(new GraphQLError(message, { nodes: [operation] })); + } + }, + }); +} diff --git a/backend/server/src/rateLimit.ts b/backend/server/src/rateLimit.ts new file mode 100644 index 0000000..f7ff2b4 --- /dev/null +++ b/backend/server/src/rateLimit.ts @@ -0,0 +1,38 @@ +import { GraphQLError } from "graphql"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { config } from "./config.js"; + +const publishLimiter = new RateLimiterMemory({ + points: config.limits.publishMax, + duration: config.limits.publishWindowSeconds, +}); + +/** + * Counts one build publishing attempt for the client and throws a rate limit error + * once the client has used up its attempts. Must run before anything is written. + */ +export async function consumePublishAttempt(ip: string): Promise { + try { + await publishLimiter.consume(ip); + } catch (rejection) { + // The limiter rejects with its result object when the limit is reached. + const retryAfter = Math.max( + 1, + Math.ceil( + ((rejection as { msBeforeNext?: number }).msBeforeNext ?? 0) / 1000, + ), + ); + throw new GraphQLError( + `Too many builds published. Try again in ${retryAfter} seconds.`, + { + extensions: { + code: "RATE_LIMITED", + http: { + status: 429, + headers: new Map([["Retry-After", String(retryAfter)]]), + }, + }, + }, + ); + } +} diff --git a/backend/server/src/resolvers.ts b/backend/server/src/resolvers.ts index 92d97be..e551ba9 100644 --- a/backend/server/src/resolvers.ts +++ b/backend/server/src/resolvers.ts @@ -1,6 +1,14 @@ +import DataLoader from "dataloader"; import { GraphQLError, GraphQLScalarType } from "graphql"; import path from "node:path"; import { pool } from "./db.js"; +import { consumePublishAttempt } from "./rateLimit.js"; + +/** Per-request values made available to every resolver. */ +export interface Context { + ip: string; + buildParts: ReturnType; +} const MAX_PAGE_SIZE = 100; @@ -386,7 +394,10 @@ async function publishBuild( }: { input: { tittel: string; comment?: string | null; partIds: string[] }; }, + { ip }: Context, ) { + // Counted first, so blocked or invalid attempts never reach the database. + await consumePublishAttempt(ip); const tittel = input.tittel.trim(); const comment = input.comment?.trim() || null; const invalid = (message: string) => @@ -437,6 +448,52 @@ async function publishBuild( return rows[0]; } +async function build(_: unknown, { id }: { id: string }) { + if (!/^[1-9]\d{0,17}$/.test(id)) { + throw new GraphQLError("Invalid build ID", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + const { rows } = await pool.query("SELECT * FROM builds WHERE id = $1", [id]); + return rows[0] ?? null; +} + +const PART_ID_COLUMNS = [ + "cpu_id", + "gpu_id", + "ram_id", + "storage_id", + "motherboard_id", + "psu_id", + "case_id", + "cooler_id", +] as const; + +/** + * Creates a loader for the parts of builds. All builds requested in the same tick are + * fetched with one query, instead of one query per build. Create one per request. + */ +export function createBuildPartsLoader() { + return new DataLoader, PartRow[], unknown>( + async (builds) => { + const idsOf = (build: Record) => + PART_ID_COLUMNS.map((column) => build[column]).filter(Boolean); + const allIds = [...new Set(builds.flatMap(idsOf))]; + if (!allIds.length) return builds.map(() => []); + + const { rows } = await pool.query( + `SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`, + [allIds], + ); + return builds.map((build) => { + const ids = new Set(idsOf(build)); + return rows.filter((part) => ids.has(part.id)); + }); + }, + { cacheKeyFn: (build) => build["id"] }, + ); +} + /** Passes arbitrary JSON (the specs column) through unchanged. */ const JSONScalar = new GraphQLScalarType({ name: "JSON", @@ -451,6 +508,7 @@ export const resolvers = { parts, part, manufacturers, + build, builds: async ( _: unknown, { @@ -478,23 +536,11 @@ export const resolvers = { }, Build: { createdAt: (row: { created_at: Date }) => row.created_at.toISOString(), - parts: async (row: Record) => { - const ids = [ - row.cpu_id, - row.gpu_id, - row.ram_id, - row.storage_id, - row.motherboard_id, - row.psu_id, - row.case_id, - row.cooler_id, - ].filter(Boolean); - const { rows } = await pool.query( - `SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`, - [ids], - ); - return rows; - }, + parts: ( + row: Record, + _args: unknown, + { buildParts }: Context, + ) => buildParts.load(row), }, Part: { priceEur: (row: PartRow) => row.price_eur, diff --git a/backend/server/src/schema.ts b/backend/server/src/schema.ts index 4ec4210..96d1299 100644 --- a/backend/server/src/schema.ts +++ b/backend/server/src/schema.ts @@ -83,9 +83,11 @@ export const typeDefs = /* GraphQL */ ` comment: String createdAt: String! parts: [Part!]! + isPublished: Boolean! } input PublishBuildInput { + id: ID tittel: String! comment: String partIds: [ID!]! @@ -93,6 +95,13 @@ export const typeDefs = /* GraphQL */ ` type Mutation { publishBuild(input: PublishBuildInput!): Build! + savePrivateBuild(input: PublishBuildInput!): Build! + deleteBuild(id: ID!): Boolean! + } + + type User { + id: ID! + displayName: String } type Query { @@ -113,9 +122,12 @@ export const typeDefs = /* GraphQL */ ` ): PartConnection! part(id: ID!): Part + build(id: ID!): Build builds(limit: Int = 20, offset: Int = 0): [Build!]! - build(id: ID!): Build + me: User + myBuilds(limit: Int = 21, offset: Int = 0): [Build!]! + myBuild(id: ID!): Build "Distinct manufacturers, optionally only those that have parts in the given category" manufacturers(category: Category): [String!]! diff --git a/backend/server/src/test/buildPartsBatching.test.ts b/backend/server/src/test/buildPartsBatching.test.ts new file mode 100644 index 0000000..c0d479a --- /dev/null +++ b/backend/server/src/test/buildPartsBatching.test.ts @@ -0,0 +1,151 @@ +import { ApolloServer } from "@apollo/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); + +import { createBuildPartsLoader, resolvers } from "../resolvers.js"; +import { typeDefs } from "../schema.js"; + +type Row = Record; + +// Every build gets its own CPU and GPU, plus (optionally) one RAM kit that all builds share. +function setUpDatabase(buildCount: number, { sharedRam = false } = {}) { + const parts: Row[] = []; + const builds: Row[] = Array.from({ length: buildCount }, (_, index) => { + const number = index + 1; + parts.push( + { id: `gpu-${number}`, category: "GPU", name: `GPU ${number}` }, + { id: `cpu-${number}`, category: "CPU", name: `CPU ${number}` }, + ); + return { + id: String(number), + tittel: `Build ${number}`, + created_at: new Date("2026-01-01T00:00:00Z"), + cpu_id: `cpu-${number}`, + gpu_id: `gpu-${number}`, + ram_id: sharedRam ? "ram-shared" : null, + }; + }); + if (sharedRam) { + parts.push({ id: "ram-shared", category: "RAM", name: "Shared RAM" }); + } + + query.mockImplementation(async (sql: string, params: unknown[]) => { + if (sql.startsWith("SELECT * FROM builds")) return { rows: builds }; + const ids = params[0] as string[]; + // Like the real query, the parts come back ordered by category. + return { + rows: parts + .filter((part) => ids.includes(part.id as string)) + .sort((a, b) => String(a.category).localeCompare(String(b.category))), + }; + }); +} + +async function listBuilds() { + const server = new ApolloServer({ typeDefs, resolvers }); + const response = await server.executeOperation( + { + query: `{ builds(limit: 100) { id parts { id name category } } }`, + }, + { + contextValue: { ip: "10.0.0.1", buildParts: createBuildPartsLoader() }, + }, + ); + if (response.body.kind !== "single") throw new Error("Unexpected response"); + const { data, errors } = response.body.singleResult; + expect(errors).toBeUndefined(); + return ( + data as { + builds: { id: string; parts: { id: string; category: string }[] }[]; + } + ).builds; +} + +const partsQueries = () => + query.mock.calls.filter(([sql]) => String(sql).includes("FROM parts")); + +beforeEach(() => { + query.mockReset(); +}); + +describe("loading the parts of published builds", () => { + it("returns every build with its own parts", async () => { + setUpDatabase(20); + + const builds = await listBuilds(); + + expect(builds).toHaveLength(20); + for (const build of builds) { + expect(build.parts.map((part) => part.id)).toEqual([ + `cpu-${build.id}`, + `gpu-${build.id}`, + ]); + } + }); + + it("uses one query for the builds and one for all of their parts", async () => { + setUpDatabase(20); + + await listBuilds(); + + expect(query).toHaveBeenCalledTimes(2); + expect(partsQueries()).toHaveLength(1); + }); + + it.each([1, 5, 20, 100])( + "keeps the number of queries at two for %i builds", + async (count) => { + setUpDatabase(count); + + const builds = await listBuilds(); + + expect(builds).toHaveLength(count); + expect(query).toHaveBeenCalledTimes(2); + }, + ); + + it("asks for each part only once and sends the IDs as a parameter", async () => { + setUpDatabase(3, { sharedRam: true }); + + const builds = await listBuilds(); + + const [sql, params] = partsQueries()[0]!; + expect(sql).toContain("id = ANY($1::uuid[])"); + expect(params[0]).toHaveLength(7); // 3 CPUs + 3 GPUs + the shared RAM, once + for (const build of builds) { + expect(build.parts.map((part) => part.category)).toEqual([ + "CPU", + "GPU", + "RAM", + ]); + } + }); + + it("skips the parts query when no build has any parts", async () => { + query.mockImplementation(async () => ({ + rows: [ + { id: "1", tittel: "Empty", created_at: new Date() }, + { id: "2", tittel: "Also empty", created_at: new Date() }, + ], + })); + + const builds = await listBuilds(); + + expect(builds.map((build) => build.parts)).toEqual([[], []]); + expect(query).toHaveBeenCalledTimes(1); + }); + + it("uses a separate loader, and so a fresh query, for each request", async () => { + setUpDatabase(2); + + await listBuilds(); + await listBuilds(); + + expect(partsQueries()).toHaveLength(2); + }); +}); diff --git a/backend/server/src/test/catalogResolvers.test.ts b/backend/server/src/test/catalogResolvers.test.ts new file mode 100644 index 0000000..0299bb8 --- /dev/null +++ b/backend/server/src/test/catalogResolvers.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); + +import { resolvers } from "../resolvers.js"; + +const { parts, part, manufacturers, builds } = resolvers.Query; + +const partId = "11111111-1111-4111-8111-111111111111"; +const defaults = { + sortBy: "NAME", + sortDirection: "ASC", + first: 20, +} as const; +const idA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const idB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const row = (id: string, name: string) => ({ id, name, price_eur: 10 }); + +beforeEach(() => { + query.mockReset(); +}); + +describe("Query.parts", () => { + beforeEach(() => { + query.mockImplementation(async (sql: string) => + sql.startsWith("SELECT count") + ? { rows: [{ count: "2" }] } + : { rows: [row(idA, "Alpha"), row(idB, "Beta")] }, + ); + }); + + const lastPageQuery = () => query.mock.calls.at(-1)!; + + it("returns the page with the total count and page info", async () => { + const result = await parts(null, { ...defaults }); + + expect(result.totalCount).toBe(2); + expect(result.edges.map((edge) => edge.node.name)).toEqual([ + "Alpha", + "Beta", + ]); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: result.edges[1]!.cursor, + }); + }); + + it("reports another page when there are more rows than requested", async () => { + const result = await parts(null, { ...defaults, first: 1 }); + + expect(result.edges).toHaveLength(1); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.endCursor).toBe(result.edges[0]!.cursor); + }); + + it("continues after a cursor from the previous page", async () => { + const first = await parts(null, { ...defaults, first: 1 }); + query.mockClear(); + + await parts(null, { + ...defaults, + first: 1, + after: first.pageInfo.endCursor, + }); + + // Only the page query is sent, and the cursor's sort value and ID are parameters. + expect(lastPageQuery()[1]).toEqual(expect.arrayContaining(["Alpha", idA])); + }); + + it.each([0, -1, 101])( + "rejects a page size of %i without querying", + async (size) => { + await expect( + parts(null, { ...defaults, first: size }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(query).not.toHaveBeenCalled(); + }, + ); + + it.each(["not a cursor", Buffer.from("{}").toString("base64url")])( + "rejects the invalid cursor %j", + async (after) => { + await expect(parts(null, { ...defaults, after })).rejects.toMatchObject({ + message: "Invalid cursor", + extensions: { code: "BAD_USER_INPUT" }, + }); + }, + ); + + it("filters by category with a parameter", async () => { + await parts(null, { ...defaults, category: "GPU" }); + + const [sql, params] = lastPageQuery(); + expect(sql).toContain("category = $1"); + expect(params[0]).toBe("GPU"); + }); + + it("requires every search word to match, with wildcards escaped", async () => { + await parts(null, { ...defaults, search: "rtx 100%_" }); + + const [sql, params] = lastPageQuery(); + expect(sql.match(/name ILIKE/g)).toHaveLength(2); + expect(params).toEqual(expect.arrayContaining(["%rtx%", "%100\\%\\_%"])); + }); + + it("never puts user input into the SQL text", async () => { + await parts(null, { + ...defaults, + search: "'; DROP TABLE parts; --", + manufacturers: ["Evil'; --"], + }); + + for (const [sql] of query.mock.calls) { + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain("Evil"); + } + }); + + it("matches manufacturers case-insensitively", async () => { + await parts(null, { ...defaults, manufacturers: ["NVIDIA", "AMD"] }); + + expect(lastPageQuery()[1]).toContainEqual(["nvidia", "amd"]); + }); + + it("sorts by the requested column and direction", async () => { + await parts(null, { ...defaults, sortBy: "PRICE", sortDirection: "DESC" }); + + expect(lastPageQuery()[0]).toContain("ORDER BY price_eur DESC NULLS LAST"); + }); + + describe("attribute filters", () => { + it("require a category", async () => { + await expect( + parts(null, { ...defaults, attributeFilters: { socket: "AM5" } }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + + it("apply to the category's specifications with parameters", async () => { + await parts(null, { + ...defaults, + category: "CPU", + attributeFilters: { socket: "AM5" }, + }); + + const [sql, params] = lastPageQuery(); + expect(sql).toContain("specs->>'socket' = $2"); + expect(params).toContain("AM5"); + }); + + it("are ignored when none are set", async () => { + await expect( + parts(null, { ...defaults, attributeFilters: { socket: null } }), + ).resolves.toMatchObject({ totalCount: 2 }); + }); + + it("reject a minimum capacity above the maximum", async () => { + await expect( + parts(null, { + ...defaults, + category: "RAM", + attributeFilters: { minCapacityGb: 64, maxCapacityGb: 16 }, + }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + + it("reject a category that does not support them", async () => { + await expect( + parts(null, { + ...defaults, + category: "Keyboard", + attributeFilters: { socket: "AM5" }, + }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + }); +}); + +describe("Query.part", () => { + it("returns the part with a parameterized query", async () => { + const found = row(partId, "RTX"); + query.mockResolvedValue({ rows: [found] }); + + await expect(part(null, { id: partId })).resolves.toBe(found); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("WHERE id = $1"), + [partId], + ); + }); + + it("returns null when the part does not exist", async () => { + query.mockResolvedValue({ rows: [] }); + + await expect(part(null, { id: partId })).resolves.toBeNull(); + }); + + it.each(["abc", "", "1", `${partId}'; DROP TABLE parts;--`])( + "returns null for the invalid ID %j without querying", + async (id) => { + await expect(part(null, { id })).resolves.toBeNull(); + expect(query).not.toHaveBeenCalled(); + }, + ); +}); + +describe("Query.manufacturers", () => { + it("returns the manufacturer names", async () => { + query.mockResolvedValue({ + rows: [{ manufacturer: "AMD" }, { manufacturer: "Intel" }], + }); + + await expect(manufacturers(null, { category: "CPU" })).resolves.toEqual([ + "AMD", + "Intel", + ]); + expect(query).toHaveBeenCalledWith(expect.any(String), ["CPU"]); + }); + + it("passes null to include every category", async () => { + query.mockResolvedValue({ rows: [] }); + + await manufacturers(null, {}); + + expect(query).toHaveBeenCalledWith(expect.any(String), [null]); + }); +}); + +describe("Query.builds", () => { + it("returns builds using the default page size", async () => { + const rows = [{ id: "2" }, { id: "1" }]; + query.mockResolvedValue({ rows }); + + await expect(builds(null, {})).resolves.toBe(rows); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("LIMIT $1 OFFSET $2"), + [20, 0], + ); + }); + + it("passes a custom limit and offset", async () => { + query.mockResolvedValue({ rows: [] }); + + await builds(null, { limit: 5, offset: 10 }); + + expect(query).toHaveBeenCalledWith(expect.any(String), [5, 10]); + }); + + it.each([ + [0, 0], + [101, 0], + [20, -1], + [null, 0], + [20, null], + ])( + "rejects limit %s with offset %s without querying", + async (limit, offset) => { + await expect(builds(null, { limit, offset })).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + expect(query).not.toHaveBeenCalled(); + }, + ); +}); + +describe("field resolvers", () => { + it("exposes the price as priceEur", () => { + expect(resolvers.Part.priceEur({ price_eur: 99.5 } as never)).toBe(99.5); + }); + + it("builds the image URL from the file name only", () => { + const imageUrl = (image_path: string | null) => + resolvers.Part.imageUrl({ image_path } as never); + + expect(imageUrl("part_images/abc.webp")).toBe("/images/abc.webp"); + expect(imageUrl("../../secret/abc.webp")).toBe("/images/abc.webp"); + expect(imageUrl(null)).toBeNull(); + }); + + it("formats the build creation time as an ISO string", () => { + expect( + resolvers.Build.createdAt({ + created_at: new Date("2026-01-02T03:04:05.000Z"), + }), + ).toBe("2026-01-02T03:04:05.000Z"); + }); +}); diff --git a/backend/server/src/test/limits.test.ts b/backend/server/src/test/limits.test.ts new file mode 100644 index 0000000..e97d905 --- /dev/null +++ b/backend/server/src/test/limits.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildSchema, parse, specifiedRules, validate } from "graphql"; +import { limitOperationSize } from "../limits.js"; +import { typeDefs } from "../schema.js"; + +const schema = buildSchema(typeDefs); + +function errors(query: string, maxDepth = 8, maxFields = 100) { + return validate(schema, parse(query), [ + ...specifiedRules, + limitOperationSize({ maxDepth, maxFields }), + ]).map((error) => error.message); +} + +describe("limitOperationSize", () => { + it("accepts normal catalog and build queries", () => { + expect( + errors(`{ parts(first: 40) { edges { node { id name priceEur } cursor } + totalCount pageInfo { hasNextPage endCursor } } }`), + ).toEqual([]); + expect(errors(`{ builds { id tittel parts { id name } } }`)).toEqual([]); + }); + + it("rejects queries nested deeper than the maximum", () => { + // parts > edges > node > id is 4 levels deep. + const query = `{ parts { edges { node { id } } } }`; + expect(errors(query, 4)).toEqual([]); + expect(errors(query, 3)).toEqual([ + "Query is nested too deeply (maximum depth is 3).", + ]); + }); + + it("rejects queries that select too many fields", () => { + const aliases = Array.from( + { length: 11 }, + (_, index) => `a${index}: manufacturers`, + ).join(" "); + expect(errors(`{ ${aliases} }`, 8, 10)).toEqual([ + "Query selects too many fields (maximum is 10).", + ]); + }); + + it("counts fields and depth inside fragments", () => { + const query = ` + query { builds { ...BuildFields } } + fragment BuildFields on Build { id parts { ...PartFields } } + fragment PartFields on Part { id name }`; + expect(errors(query, 3)).toEqual([]); + expect(errors(query, 2)).toEqual([ + "Query is nested too deeply (maximum depth is 2).", + ]); + expect(errors(query, 8, 4)).toEqual([ + "Query selects too many fields (maximum is 4).", + ]); + }); + + it("does not loop on fragment cycles", () => { + const query = ` + { builds { ...A } } + fragment A on Build { id ...B } + fragment B on Build { id ...A }`; + expect(errors(query)).not.toContain(expect.stringContaining("too")); + }); + + it("does not limit introspection queries", () => { + expect( + errors(`{ __schema { types { fields { type { ofType { ofType { ofType { + ofType { ofType { name } } } } } } } } } }`), + ).toEqual([]); + }); +}); diff --git a/backend/server/src/test/publishBuild.test.ts b/backend/server/src/test/publishBuild.test.ts new file mode 100644 index 0000000..8ef4c84 --- /dev/null +++ b/backend/server/src/test/publishBuild.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 1000, publishWindowSeconds: 600 } }, +})); + +import { resolvers } from "../resolvers.js"; + +type Input = { tittel: string; comment?: string | null; partIds: string[] }; + +const publishBuild = (input: Input) => + resolvers.Mutation.publishBuild(null, { input }, { ip: "10.0.0.1" }); + +// Fixed order used for the INSERT: CPU, GPU, RAM, Storage, Motherboard, PSU, PCCase, CPUCooler. +const parts = [ + ["11111111-1111-4111-8111-111111111111", "CPU"], + ["22222222-2222-4222-8222-222222222222", "GPU"], + ["33333333-3333-4333-8333-333333333333", "RAM"], + ["44444444-4444-4444-8444-444444444444", "Storage"], + ["55555555-5555-4555-8555-555555555555", "Motherboard"], + ["66666666-6666-4666-8666-666666666666", "PSU"], + ["77777777-7777-4777-8777-777777777777", "PCCase"], + ["88888888-8888-4888-8888-888888888888", "CPUCooler"], +] as const; +const [cpu, gpu, , , motherboard] = parts.map(([id]) => id); + +const inserts = () => + query.mock.calls.filter(([sql]) => String(sql).startsWith("INSERT")); + +beforeEach(() => { + query.mockReset(); + // Looking up parts returns the requested parts; inserting returns the new build. + query.mockImplementation(async (sql: string, params: unknown[]) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1", tittel: params[0], comment: params[1] }] } + : { + rows: parts + .filter(([id]) => (params[0] as string[]).includes(id)) + .map(([id, category]) => ({ id, category })), + }, + ); +}); + +describe("publishing a valid build", () => { + it("saves the build and returns it", async () => { + await expect( + publishBuild({ + tittel: "Gaming PC", + comment: "Fast", + partIds: [cpu, gpu], + }), + ).resolves.toMatchObject({ id: "1", tittel: "Gaming PC", comment: "Fast" }); + expect(inserts()).toHaveLength(1); + }); + + it("puts each part in its own column, whatever order the IDs are sent in", async () => { + await publishBuild({ tittel: "Build", partIds: [motherboard, cpu, gpu] }); + + const [, params] = inserts()[0]!; + // title, comment, then CPU, GPU, RAM, Storage, Motherboard, PSU, case, cooler + expect(params).toEqual([ + "Build", + null, + cpu, + gpu, + null, + null, + motherboard, + null, + null, + null, + ]); + }); + + it("accepts all eight categories", async () => { + await expect( + publishBuild({ tittel: "Full", partIds: parts.map(([id]) => id) }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("trims the title and comment, and stores an empty comment as null", async () => { + await publishBuild({ tittel: " Build ", comment: " ", partIds: [cpu] }); + + expect(inserts()[0]![1]).toEqual(expect.arrayContaining(["Build", null])); + expect(inserts()[0]![1].slice(0, 2)).toEqual(["Build", null]); + }); + + it("accepts a title of 50 characters and a comment of 200", async () => { + await expect( + publishBuild({ + tittel: "t".repeat(50), + comment: "c".repeat(200), + partIds: [cpu], + }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("counts characters, not UTF-16 units, when checking the title length", async () => { + // 50 emoji are 100 UTF-16 units but 50 characters. + await expect( + publishBuild({ tittel: "😀".repeat(50), partIds: [cpu] }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("sends user input as query parameters, never inside the SQL text", async () => { + const title = "x'); DROP TABLE builds; --"; + await publishBuild({ tittel: title, partIds: [cpu] }); + + for (const [sql] of query.mock.calls) { + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain(cpu); + } + expect(inserts()[0]![0]).toContain("$10"); + expect(inserts()[0]![1][0]).toBe(title); + }); +}); + +describe("rejecting invalid build requests", () => { + const invalidRequests: [string, Input][] = [ + ["an empty title", { tittel: "", partIds: [cpu] }], + ["a title with only spaces", { tittel: " ", partIds: [cpu] }], + ["a title over 50 characters", { tittel: "t".repeat(51), partIds: [cpu] }], + [ + "a comment over 200 characters", + { tittel: "Build", comment: "c".repeat(201), partIds: [cpu] }, + ], + ["no parts", { tittel: "Build", partIds: [] }], + [ + "more than 8 parts", + { + tittel: "Build", + partIds: [...parts.map(([id]) => id), cpu], + }, + ], + ["a part ID that is not a UUID", { tittel: "Build", partIds: ["abc"] }], + [ + "an injection attempt as a part ID", + { tittel: "Build", partIds: [`${cpu}'; DROP TABLE parts;--`] }, + ], + ["the same part twice", { tittel: "Build", partIds: [cpu, cpu] }], + [ + "a part that does not exist", + { tittel: "Build", partIds: ["99999999-9999-4999-8999-999999999999"] }, + ], + ]; + + it.each(invalidRequests)( + "rejects %s without saving anything", + async (_, input) => { + await expect(publishBuild(input)).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + expect(inserts()).toHaveLength(0); + }, + ); + + it("rejects two parts from the same category", async () => { + const otherCpu = "99999999-9999-4999-8999-999999999999"; + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { + rows: [ + { id: cpu, category: "CPU" }, + { id: otherCpu, category: "CPU" }, + ], + }, + ); + + await expect( + publishBuild({ tittel: "Build", partIds: [cpu, otherCpu] }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(inserts()).toHaveLength(0); + }); + + it("rejects a part from a category that cannot be built", async () => { + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { rows: [{ id: cpu, category: "Keyboard" }] }, + ); + + await expect( + publishBuild({ tittel: "Build", partIds: [cpu] }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(inserts()).toHaveLength(0); + }); + + it("does not query the database for requests that are invalid on their own", async () => { + await expect( + publishBuild({ tittel: "", partIds: [cpu] }), + ).rejects.toThrow(); + await expect( + publishBuild({ tittel: "Build", partIds: ["abc"] }), + ).rejects.toThrow(); + await expect( + publishBuild({ tittel: "Build", partIds: [] }), + ).rejects.toThrow(); + // Nine different, valid IDs: one more than the eight categories allow. + const nine = Array.from( + { length: 9 }, + (_, index) => `${index}0000000-0000-4000-8000-000000000000`, + ); + await expect( + publishBuild({ tittel: "Build", partIds: nine }), + ).rejects.toThrow(); + + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/server/src/test/publishRateLimit.test.ts b/backend/server/src/test/publishRateLimit.test.ts new file mode 100644 index 0000000..b9a5e70 --- /dev/null +++ b/backend/server/src/test/publishRateLimit.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 2, publishWindowSeconds: 600 } }, +})); + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); + +import { resolvers } from "../resolvers.js"; + +const partId = "11111111-1111-4111-8111-111111111111"; +const input = { tittel: "My build", partIds: [partId] }; +const publish = (ip: string) => + resolvers.Mutation.publishBuild(null, { input }, { ip }); +const inserts = () => + query.mock.calls.filter(([sql]) => String(sql).startsWith("INSERT")); + +beforeEach(() => { + query.mockReset(); + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { rows: [{ id: partId, category: "CPU" }] }, + ); +}); + +describe("publishBuild rate limit", () => { + it("allows normal publishing", async () => { + await expect(publish("10.0.0.1")).resolves.toEqual({ id: "1" }); + expect(inserts()).toHaveLength(1); + }); + + it("blocks excessive publishing without creating database entries", async () => { + await publish("10.0.0.2"); + await publish("10.0.0.2"); + expect(inserts()).toHaveLength(2); + query.mockClear(); + + await expect(publish("10.0.0.2")).rejects.toMatchObject({ + extensions: { + code: "RATE_LIMITED", + http: { status: 429, headers: expect.any(Map) }, + }, + }); + expect(query).not.toHaveBeenCalled(); + }); + + it("limits each IP address separately", async () => { + await publish("10.0.0.3"); + await publish("10.0.0.3"); + await expect(publish("10.0.0.3")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + + await expect(publish("10.0.0.4")).resolves.toEqual({ id: "1" }); + }); + + it("counts invalid attempts too", async () => { + const invalid = (ip: string) => + resolvers.Mutation.publishBuild( + null, + { input: { tittel: "", partIds: [partId] } }, + { ip }, + ); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + }); +}); diff --git a/backend/server/src/test/resolvers.test.ts b/backend/server/src/test/resolvers.test.ts new file mode 100644 index 0000000..fff68c9 --- /dev/null +++ b/backend/server/src/test/resolvers.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); + +import { createBuildPartsLoader, resolvers } from "../resolvers.js"; + +const build = resolvers.Query.build; + +describe("Query.build", () => { + beforeEach(() => query.mockReset()); + + it("returns the build with a parameterized query", async () => { + const row = { id: "42", tittel: "My build", created_at: new Date() }; + query.mockResolvedValue({ rows: [row] }); + + await expect(build(null, { id: "42" })).resolves.toBe(row); + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("WHERE id = $1"), + ["42"], + ); + }); + + it("returns null when the build does not exist", async () => { + query.mockResolvedValue({ rows: [] }); + + await expect(build(null, { id: "999" })).resolves.toBeNull(); + }); + + it.each([ + "abc", + "", + "0", + "-1", + "1.5", + "1; DROP TABLE builds", + "1234567890123456789", + ])("rejects invalid ID %j without querying the database", async (id) => { + await expect(build(null, { id })).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + expect(query).not.toHaveBeenCalled(); + }); +}); + +describe("Build.parts", () => { + beforeEach(() => query.mockReset()); + + it("loads the components referenced by the build", async () => { + const parts = [{ id: "p1" }, { id: "p2" }]; + query.mockResolvedValue({ rows: parts }); + + const result = await resolvers.Build.parts( + { id: "1", cpu_id: "p1", gpu_id: null, ram_id: "p2" }, + {}, + { buildParts: createBuildPartsLoader() } as never, + ); + + expect(result).toEqual(parts); + expect(query).toHaveBeenCalledWith(expect.stringContaining("$1"), [ + ["p1", "p2"], + ]); + }); +}); diff --git a/backend/server/src/users.ts b/backend/server/src/users.ts new file mode 100644 index 0000000..4ad341d --- /dev/null +++ b/backend/server/src/users.ts @@ -0,0 +1,29 @@ +import { pool } from "./db.js"; + +export type DatabaseUser = { + id: string; + firebase_uid: string; + display_name: string | null; +}; + +export async function findOrCreateUser( + uid: string, + displayName: string | null, +): Promise { + const { rows } = await pool.query( + `INSERT INTO users (firebase_uid, display_name) + VALUES ($1, $2) + ON CONFLICT (firebase_uid) + DO UPDATE SET display_name = EXCLUDED.display_name + RETURNING id::text, firebase_uid, display_name`, + [uid, displayName], + ); + + const user = rows[0]; + + if (!user) { + throw new Error("Could not create or retrieve user."); + } + + return user; +} \ No newline at end of file diff --git a/backend/server/test/buildOwnership.test.mjs b/backend/server/test/buildOwnership.test.mjs new file mode 100644 index 0000000..43efed9 --- /dev/null +++ b/backend/server/test/buildOwnership.test.mjs @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import process from "node:process"; +import { after, beforeEach, test } from "node:test"; +import { ApolloServer } from "@apollo/server"; + +// Exercise the actual GraphQL schema/resolvers with isolated database fixtures. +// No connection to the VM or real user data is made. +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +const { pool } = await import("../dist/db.js"); +const { resolvers } = await import("../dist/authenticatedResolvers.js"); +const { createBuildPartsLoader } = await import("../dist/resolvers.js"); +const { typeDefs } = await import("../dist/schema.js"); +const { createAuthContext } = await import("../dist/auth.js"); +const server = new ApolloServer({ typeDefs, resolvers }); +const originalQuery = pool.query; +const cpuId = "11111111-1111-4111-8111-111111111111"; +const contexts = { + a: { + uid: "firebase-a", + user: { id: "10", firebase_uid: "firebase-a", display_name: "A" }, + }, + b: { + uid: "firebase-b", + user: { id: "20", firebase_uid: "firebase-b", display_name: "B" }, + }, + guest: { uid: null, user: null }, +}; +let builds; +const calls = []; + +beforeEach(() => { + calls.length = 0; + builds = new Map( + [ + ["1", { id: "1", owner_id: "10", is_published: false }], + ["2", { id: "2", owner_id: "10", is_published: true }], + ["3", { id: "3", owner_id: "20", is_published: false }], + ["4", { id: "4", owner_id: null, is_published: true }], + ].map(([id, row]) => [ + id, + { + ...row, + tittel: `Build ${id}`, + comment: null, + created_at: new Date("2026-11-13T12:00:00Z"), + cpu_id: cpuId, + }, + ]), + ); +}); + +pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes("FROM parts")) { + return { + rows: [ + { + id: cpuId, + category: "CPU", + name: "Test CPU", + manufacturer: "Test", + price_eur: 10, + image_path: null, + specs: {}, + }, + ], + }; + } + if (sql.startsWith("DELETE FROM builds")) { + assert.match(sql, /WHERE id = \$1 AND owner_id = \$2/); + const row = builds.get(params[0]); + if (!row || row.owner_id !== params[1]) return { rowCount: 0, rows: [] }; + builds.delete(row.id); + return { rowCount: 1, rows: [] }; + } + if (sql.includes("UPDATE builds SET")) { + assert.match(sql, /WHERE id = \$13 AND owner_id = \$11/); + const row = builds.get(params[12]); + if (!row || row.owner_id !== params[10]) return { rows: [] }; + Object.assign(row, { + tittel: params[0], + comment: params[1], + is_published: params[11], + }); + return { rows: [row] }; + } + if (sql.includes("INSERT INTO builds")) { + const id = String(builds.size + 10); + const row = { + id, + owner_id: params[10], + is_published: params[11], + tittel: params[0], + comment: params[1], + created_at: new Date(), + cpu_id: cpuId, + }; + builds.set(id, row); + return { rows: [row] }; + } + if (sql.includes("FROM builds WHERE id") && sql.includes("is_published = true")) { + const row = builds.get(params[0]); + return { rows: row?.is_published ? [row] : [] }; + } + if (sql.includes("FROM builds WHERE id")) { + assert.match(sql, /id = \$1 AND owner_id = \$2/); + const row = builds.get(params[0]); + return { rows: row?.owner_id === params[1] ? [row] : [] }; + } + if (sql.includes("WHERE owner_id = $1")) { + return { + rows: [...builds.values()] + .filter((row) => row.owner_id === params[0]) + .slice(params[2], params[2] + params[1]), + }; + } + if (sql.includes("WHERE is_published = true")) { + return { + rows: [...builds.values()] + .filter((row) => row.is_published) + .slice(params[1], params[1] + params[0]), + }; + } + throw new Error("Unexpected database operation in ownership test"); +}; + +after(async () => { + pool.query = originalQuery; + await server.stop(); + await pool.end(); +}); + +let requestNumber = 0; +async function request(query, variables = {}, account = "a") { + const result = await server.executeOperation( + { query, variables }, + { + contextValue: { + ...contexts[account], + ip: `fixture-${++requestNumber}`, + buildParts: createBuildPartsLoader(), + }, + }, + ); + assert.equal(result.body.kind, "single"); + return result.body.singleResult; +} +const input = { tittel: "Changed", comment: "", partIds: [cpuId] }; +const save = (mutation, id, account = "a") => + request( + `mutation($input: PublishBuildInput!) { ${mutation}(input: $input) { id isPublished } }`, + { input: { ...input, ...(id ? { id } : {}) } }, + account, + ); + +test("anonymous callers cannot list, reopen, save, publish or delete account builds", async () => { + const results = [ + await request("{ myBuilds { id } }", {}, "guest"), + await request('{ myBuild(id: "1") { id } }', {}, "guest"), + await save("savePrivateBuild", null, "guest"), + await save("publishBuild", null, "guest"), + await request('mutation { deleteBuild(id: "1") }', {}, "guest"), + ]; + for (const result of results) + assert.equal(result.errors[0].extensions.code, "UNAUTHENTICATED"); + assert.equal(calls.length, 0); +}); + +test("two accounts see only their own saved builds; private data is inaccessible to the other", async () => { + assert.deepEqual( + (await request("{ myBuilds { id } }")).data.myBuilds.map((row) => row.id), + ["1", "2"], + ); + assert.deepEqual( + (await request("{ myBuilds { id } }", {}, "b")).data.myBuilds.map( + (row) => row.id, + ), + ["3"], + ); + assert.equal( + (await request('{ myBuild(id: "1") { id } }', {}, "b")).data.myBuild, + null, + ); + const own = await request('{ myBuild(id: "1") { id parts { name specs } } }'); + assert.equal(own.data.myBuild.id, "1"); + assert.equal(own.data.myBuild.parts[0].name, "Test CPU"); +}); + +test("the public single-build lookup cannot expose private builds", async () => { + const hidden = await request('{ build(id: "1") { id } }', {}, "b"); + assert.equal(hidden.data.build, null); + const legacy = await request('{ build(id: "4") { id } }', {}, "guest"); + assert.equal(legacy.data.build.id, "4"); +}); + +test("Community includes public legacy builds and excludes private builds", async () => { + const result = await request("{ builds { id } }", {}, "guest"); + assert.deepEqual( + result.data.builds.map((row) => row.id), + ["2", "4"], + ); +}); + +test("another account cannot modify, publish or delete an owned build", async () => { + for (const mutation of ["savePrivateBuild", "publishBuild"]) { + const result = await save(mutation, "1", "b"); + assert.equal(result.errors[0].extensions.code, "NOT_FOUND"); + } + const result = await request('mutation { deleteBuild(id: "1") }', {}, "b"); + assert.equal(result.errors[0].extensions.code, "NOT_FOUND"); + assert.equal(builds.get("1").tittel, "Build 1"); +}); + +test("owner updates the same build and can publish or make it private", async () => { + const published = await save("publishBuild", "1"); + assert.equal(published.data.publishBuild.id, "1"); + assert.equal(builds.get("1").is_published, true); + await save("savePrivateBuild", "1"); + assert.equal(builds.get("1").is_published, false); + assert.equal(builds.size, 4); +}); + +test("saving as new creates a separate owned build and preserves the original", async () => { + const result = await save("savePrivateBuild"); + assert.equal(result.errors, undefined); + const copy = builds.get(result.data.savePrivateBuild.id); + assert.equal(copy.owner_id, "10"); + assert.equal(copy.is_published, false); + assert.equal(builds.get("1").tittel, "Build 1"); + assert.equal(builds.size, 5); +}); + +test("owner can delete their build; ownerless legacy builds cannot be claimed or deleted", async () => { + const deleted = await request('mutation { deleteBuild(id: "1") }'); + assert.equal(deleted.data.deleteBuild, true); + assert.equal(builds.has("1"), false); + const legacyDelete = await request('mutation { deleteBuild(id: "4") }'); + assert.equal(legacyDelete.errors[0].extensions.code, "NOT_FOUND"); + assert.equal( + (await save("publishBuild", "4")).errors[0].extensions.code, + "NOT_FOUND", + ); + assert.equal(builds.has("4"), true); +}); + +test("invalid IDs and empty builds are rejected before database mutations", async () => { + const invalid = await request('mutation { deleteBuild(id: "invalid") }'); + assert.equal(invalid.errors[0].extensions.code, "BAD_USER_INPUT"); + const empty = await request( + "mutation($input: PublishBuildInput!) { savePrivateBuild(input: $input) { id } }", + { input: { ...input, partIds: [] } }, + ); + assert.equal(empty.errors[0].extensions.code, "BAD_USER_INPUT"); + assert.equal(calls.length, 0); +}); + +test("authentication accepts absent credentials as anonymous and rejects malformed tokens", async () => { + assert.deepEqual(await createAuthContext(), contexts.guest); + await assert.rejects( + createAuthContext("Bearer broken-token"), + (error) => error.extensions.code === "UNAUTHENTICATED", + ); + assert.equal(calls.length, 0); +}); diff --git a/backend/server/tsconfig.json b/backend/server/tsconfig.json index 19f4844..717854a 100644 --- a/backend/server/tsconfig.json +++ b/backend/server/tsconfig.json @@ -39,5 +39,7 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true - } + }, + "include": ["src"], + "exclude": ["src/test"] } diff --git a/backend/server/vitest.config.ts b/backend/server/vitest.config.ts new file mode 100644 index 0000000..4538ca2 --- /dev/null +++ b/backend/server/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { pool: "vmThreads" }, +}); diff --git a/docs/accessibility-and-sustainability.md b/docs/accessibility-and-sustainability.md new file mode 100644 index 0000000..1efbe92 --- /dev/null +++ b/docs/accessibility-and-sustainability.md @@ -0,0 +1,83 @@ +# Accessibility and sustainability + +[Back to the README](../README.md) + +We have considered accessibility and sustainability while building Full Circuit. Below we describe our choices, examples from the implementation, and improvements we still need to make. + +## Accessibility + +We have used the four [WCAG principles](https://www.w3.org/WAI/fundamentals/accessibility-principles/) as a guide: content should be **perceivable**, **operable**, **understandable**, and **robust**. For Full Circuit, this means readable component information, controls that work without a mouse, clear feedback, and markup that assistive technologies can interpret. We have not completed a full WCAG audit. + +### Semantic HTML and screen readers + +We use `header`, `nav`, `main`, `footer`, headings, and lists to give the pages structure. The component detail views use definition lists (`dl`, `dt`, and `dd`) for specifications. We use real links for navigation and buttons for actions. + +Our form fields have labels, and icon buttons have accessible names such as **Close component details** and **Clear search**. Expandable specifications and compatibility sections expose their state with `aria-expanded`. Loading and success messages use `role="status"`, while errors use `role="alert"`. Compatibility results have text and icons as well as color, so we do not rely on color alone. + +Examples: [Header](../frontend/src/components/pageSections/Header.tsx), [desktop details](../frontend/src/components/DesktopComponentDetailPage.tsx), and [compatibility panel](../frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx). + +### Keyboard navigation and feedback + +We have added visible focus outlines so keyboard users can see which control is selected. The account cards can be reached with Tab and opened with Enter, and Delete is a separate button. The catalog has a **Load more** button as an alternative to automatic loading while scrolling. + +We use native dialogs for component details, replacement, clearing, saving, and deletion. Dialogs have accessible titles; the deletion dialog initially focuses **Keep build**. Successful component additions use a nonmodal notification, allowing us to keep browsing without being forced back to the builder. The back-to-top button returns focus to the header after scrolling. + +Examples: [focus styles](../frontend/src/index.css), [catalog](../frontend/src/components/AllPartsPreview.tsx), and [delete confirmation](../frontend/src/pages/accountPage/components/DeleteBuildButton.tsx). + +### Color contrast and images + +We have used light text against dark backgrounds and lighter status colors for readability. Using WCAG's relative-luminance formula, white text has a contrast ratio of **7.82:1** on our surface color, and the secondary text color has **4.70:1**. Our four compatibility status colors also exceed **4.5:1** on that surface. + +Orange text on the surface color is only **3.50:1**. This is below the [4.5:1 requirement for normal text](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html), so small orange links and labels still need improvement. Black text on our orange buttons has **9.40:1** contrast. These calculations cover the theme colors, not every hover state or control. + +Product images use the component name as alternative text, and catalog/detail views show a fallback when an image cannot load. We have also described the homepage illustration. Decorative icons are generally hidden from screen readers when nearby text already explains the action. + +Examples: [theme colors](../frontend/src/styles/colors.css), [product cards](../frontend/src/components/PcPartCard.tsx), and [homepage illustration](../frontend/src/pages/homePage/components/BuildPCGraphic.tsx). + +### Zoom, responsive layouts, and motion + +We have used responsive grids, wrapping text, and scrollable dialogs to keep content available on smaller screens. The builder stacks its columns on mobile, and component details have separate mobile and desktop layouts. We do not disable browser zoom. + +Several transitions respect `prefers-reduced-motion`, including the login form, detail views, and back-to-top scrolling. We still need to check motion consistently, especially the notification countdown. + +We should test text resizing at **200%** and reflow at **320 CSS pixels** or a 1280-pixel-wide browser at **400% zoom**, as described in [WCAG's reflow guidance](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html). Responsive classes alone do not prove that every view passes these checks. + +### Verification and remaining improvements + +We have reviewed the markup and calculated the contrast ratios above from `colors.css`. Automated tests cover some accessible names, dialog focus, and keyboard-style interactions, but browser APIs are mocked. We have not recorded a full keyboard-only, screen-reader, or zoom test for this documentation update. + +Our next checks are to navigate the main flows using Tab, Shift+Tab, Enter, Space, and Escape; test dialogs with VoiceOver or NVDA; and check the header, forms, and details at increased zoom. We also need to improve small orange text, add a skip-to-content link, and review focus after React Router navigation. Notifications currently disappear after five seconds, so we should check reading time and focus behavior too. + +## Sustainability + +### Color choices and images + +We have chosen a dark theme for a consistent interface. Dark colors may reduce display power on some screens, but this depends on the device and brightness; we have not measured energy savings, and readability remains important. + +We use **256 × 256 WebP images** for the 800 components, with lazy loading on catalog cards. This keeps product images small and avoids immediately downloading every offscreen image. The mobile image-layout hook also produces WebP when cropping image whitespace locally, rather than creating a PNG copy. Cropping still uses processing time and memory. + +Examples: [product cards](../frontend/src/components/PcPartCard.tsx) and [image-layout hook](../frontend/src/hooks/usePartImageLayout.ts). + +### Browser caching and API requests + +We have configured the backend image endpoint with **seven-day caching** and `immutable`, allowing browsers to reuse fresh images. If we replace an image at the same URL, it may remain cached until expiry, so we should consider versioned image URLs when updating assets. + +The catalog requests **40 components at a time** instead of the entire dataset. Its GraphQL query leaves out specifications; we fetch missing details when opening or adding a part. Cards reuse specifications while mounted. The backend SQL still reads the specifications column, so there is more work we can reduce behind the API. + +We debounce uncached searches by **300 ms** and keep matching catalog responses for **one minute**, with at most **100 pages** in memory. This avoids repeated requests when returning to recent filters. We cancel obsolete requests, although server work may already have started. For build lists, a request-scoped DataLoader batches part queries instead of fetching parts separately for every build. + +Examples: [image caching](../backend/server/src/index.ts), [catalog requests](../frontend/src/api/parts.ts), [catalog cache](../frontend/src/hooks/useParts.ts), and [build-parts batching](../backend/server/src/resolvers.ts). + +### React rendering and local storage + +We have memoized catalog cards and kept callbacks stable so unchanged cards can skip rendering. Stable IDs and retaining results while loading also avoid unnecessarily recreating cards. + +We store the current draft and saved-build metadata in local storage, separately for each account. This preserves selections across refresh and navigation without a server save for every change. Saving to PostgreSQL is explicit. Local storage does not synchronize drafts across devices, and keeping full part details uses browser storage and JSON processing. + +We still keep all loaded catalog cards mounted rather than virtualizing long lists. Caching and memoization also use memory, so these are tradeoffs rather than proof of lower energy use in every situation. + +### Verification and remaining improvements + +We have checked the image formats, cache settings, request flow, and rendering code. Tests verify catalog cache reuse, pagination, card reuse, and batched build-part queries. The current automated results are recorded in the [test README](../frontend/src/test/README.md#test-results). + +We have not measured energy consumption or carbon savings. We should use browser network tools to compare cold and cached visits, inspect actual cache headers, and measure catalog versus detail payloads. React profiling can help us check unnecessary renders. Further improvements include reducing SQL overfetching, avoiding duplicate detail requests, and considering list virtualization if measurements show a need. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f32fd9..1bfaa9b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.3", + "backend": "^0.0.0", + "firebase": "^13.2.0", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -672,261 +674,1751 @@ } } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, + "node_modules/@firebase/ai": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-3.1.0.tgz", + "integrity": "sha512-bgdItvyUeuD9ww8Zj5s+6/v1SGtf+IqrvJqgf6gjhBckpnN0sCd73L/ns7jUcKboH44vU6J7o8unRHGaPofLFg==", "license": "Apache-2.0", "dependencies": { - "@humanfs/types": "^0.15.0" + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" }, "engines": { - "node": ">=18.18.0" + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, + "node_modules/@firebase/analytics": { + "version": "0.10.25", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.25.tgz", + "integrity": "sha512-oDagV3PHeNXBdxemksZCdj+GmUT19eCnTOcLdQePIIovFXlm3zWy3Gjeaokt9Nqx24kc4ljK8lU0XGcGmmKZ1A==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.31.tgz", + "integrity": "sha512-msTlG9REujk5rjtOHNLngiuPmvaWGQte8ugL4/F6ZCMb+92Ue7SgjSEpNxINkM8ZtTrbGS83j5SZkt0r+M1Tjw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.25", + "@firebase/analytics-types": "0.8.5", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.5.tgz", + "integrity": "sha512-kdnooE7Bis2jEnsqcerRwn/UQpH5D3uvHpku7OdUM9TJN3omlu6iYtbyAQ6XkAJRgJtO0aNf9OOkW8J6D59oCA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.6.tgz", + "integrity": "sha512-WXCNElVCln7fjrVKSBWOOhyW7CD/Zjf8AGXoisSTNjRfcV5UGpIdXMsyqkneBnfPtZZbSyTZ50ThYEJg0bvSZQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" }, "engines": { - "node": ">=18.18.0" + "node": ">=24.12.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, + "node_modules/@firebase/app-check": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.13.1.tgz", + "integrity": "sha512-l8y3dmnhodXks/APAwx4tWqRl3tk8b9874KF1FJyKg1DIU+kMD0l52iz7S9/MW+eK8k6cjJg0G0iJ5KQAsQpow==", "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=18.18.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.7.tgz", + "integrity": "sha512-fBb/xSMyIKv1nDmccfzz3IAGBzx/cQOxmZai66rJzifb1hMMkztf824Xx7LhpTUe7HNUbXwY90IvJ66fi8q6yg==", "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.13.1", + "@firebase/app-check-types": "0.5.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=12.22" + "node": ">=20.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz", + "integrity": "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.5.tgz", + "integrity": "sha512-+DF4gzFrlwGFyky38o4T/YN/r51l70yCtJ2HTkwmRj8FCMwPjm4hLH4fQbj6BUvzkiFqliBkitFsRpf1/YZkWA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.22.tgz", + "integrity": "sha512-NYF84+G98do88rkuC34w+y0RbyQb/kHaJaxv7DqwveARf/XN9zJS3yrQnw1BnP5ZJjvuVwhVYJ6sXMt6NFt1Bw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.16.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "node_modules/@firebase/app-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", + "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.2" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.10.tgz", + "integrity": "sha512-Ss1REvrrfMu5hi5yduuSv6aMgppCeqBiI9tc5Lu6kTDDAirdO5bWPebT2Vz+zVIWKHciUxAN2PIrUHWniqNHTw==", "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=18.18" + "node": ">=24.12.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } } }, - "node_modules/@inquirer/ansi": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.10.tgz", - "integrity": "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/auth-compat": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.15.tgz", + "integrity": "sha512-xdrE+S3zBtnFSSomwXZp3naofEbhGrwB9BK+RRLc7A3uq0gv1HCFf2/uLVXAIrt2QYl8BohL9Uc04wHsgrXe0Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.10", + "@firebase/auth-types": "0.13.2", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" } }, - "node_modules/@inquirer/confirm": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.5.tgz", - "integrity": "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz", + "integrity": "sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.2.tgz", + "integrity": "sha512-OU+miuoSxIWYN7GT291d2ylVJBL3k/OePo7/JDSoQtkFQoEiGBc4AHuMjj/seqFIsHrqnjrAfRCk4pfufoJolQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.5.tgz", + "integrity": "sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw==", + "license": "Apache-2.0", "dependencies": { - "@inquirer/core": "^12.0.6", - "@inquirer/type": "4.2.1" + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/crashlytics": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@firebase/crashlytics/-/crashlytics-0.1.1.tgz", + "integrity": "sha512-hlMgtvEBsKB4NlIfuz9rfO+vKSfNp7yPdhkVTgFf2JlX1yezxzgMrs6MxxJ8l0CdXK9W+AOw/EtpdKxJah7PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/util": "1.15.3", + "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/browser-instrumentation": "0.6.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/exporter-logs-otlp-http": "0.220.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.220.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/instrumentation-document-load": "0.65.0", + "@opentelemetry/instrumentation-fetch": "0.220.0", + "@opentelemetry/instrumentation-xml-http-request": "0.220.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-trace-base": "2.10.0", + "@opentelemetry/sdk-trace-web": "2.10.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "tslib": "^2.1.0" + }, + "bin": { + "crashlytics-setconstants": "setconstants.js" }, "peerDependencies": { - "@types/node": ">=18" + "@angular/core": ">= 14.0.0", + "@angular/router": ">= 14.0.0", + "@firebase/app": "0.x", + "@firebase/app-types": "0.x", + "@types/react": ">= 16.8.0", + "next": ">= 13.0.0", + "react": ">= 16.8.0", + "react-router-dom": ">= 6.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "@angular/core": { + "optional": true + }, + "@angular/router": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-router-dom": { "optional": true } } }, - "node_modules/@inquirer/core": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.6.tgz", - "integrity": "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/data-connect": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.4.tgz", + "integrity": "sha512-su1aGWlzhxb+xtggCUSsufJn1FDa06SBDK71y+fpQ+g2zMhMExik6FUv/odkKzOF/xfWVjabCbWBcjJY3MceDA==", + "license": "Apache-2.0", "dependencies": { - "@inquirer/ansi": "^2.0.10", - "@inquirer/figures": "^2.0.10", - "@inquirer/type": "4.2.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.6.tgz", + "integrity": "sha512-nPktjjCWA9TSJUc0KtStBRugI6AF1b53qOlDvXkHtueIBt1sOhPzCT7S2gtxrs07S9jGA2o7J5faEa/PFAXomg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": ">=24.12.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.8.tgz", + "integrity": "sha512-wCMI122co2+rbldcZALIgmFoVn0Pj+PAuCjo0zvppMaPm/ksmK9twpmGCrmbUVjqe6xRs0YkCrKlblNujG2cpQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/database": "1.1.6", + "@firebase/database-types": "1.0.22", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" }, "peerDependencies": { - "@types/node": ">=18" + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" }, "peerDependenciesMeta": { - "@types/node": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { "optional": true } } }, - "node_modules/@inquirer/figures": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.10.tgz", - "integrity": "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==", - "dev": true, - "license": "MIT", + "node_modules/@firebase/database-types": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.22.tgz", + "integrity": "sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.6", + "@firebase/util": "1.15.3" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.20.0.tgz", + "integrity": "sha512-reF9+TgS10kA4lqLrDdP/Za5IRxt5O1FXzowZ32MQn09DQwHQa0gndXgO/p6RmPL+pxwwl8XYdU4qLuFK4XeCg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "@firebase/webchannel-wrapper": "1.0.7", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^2.8.3", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.18.tgz", + "integrity": "sha512-kGtVBhgdDRKj+4SDOtXrWIiIj/EULCxMfJKiTn1mxDmxFx/OQqZP+1BfUXnybILXSTA5gcAEMSD+QZEtS8Y6sA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/firestore": "4.20.0", + "@firebase/firestore-types": "3.0.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.5.tgz", + "integrity": "sha512-dbdMAQkMd5dwWc48eupz/Y6/E9ruat3+gY5lhVKscvvT/HnDBEEMzJW38zdKhgdnglZHGk2vUsJMOHAphMHGMA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.14.0.tgz", + "integrity": "sha512-DhuYFr0eMhp+s/PNEk6SiMsYkc00+XVOUeKbrl8MOzQNZI3SKQpqoDR1d+keNDAutwmHRWTUAdXBoVPD+xxVUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.5.0.tgz", + "integrity": "sha512-T3BDIToESZUHt7438wyKYMkRjKg3m1xAIux5fVpNvTRQlDOFLukWniQWBDhJ2znpU9kxMTR6+e31TVo8P15PZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/functions": "0.14.0", + "@firebase/functions-types": "0.6.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.5.tgz", + "integrity": "sha512-Zc0pURjthHXzSj54ZivCkzKDSV1r/wIpnmHdhq82q2yFFPVoncG/ZJjnVMiANvQfeww/QnElhzODpfwUucVwdA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.24", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.24.tgz", + "integrity": "sha512-Ui52ey8wHoWqkBbXRKJEKYWylI0JZogZmoLS+o8Anh1bxWtK27ZYjLla1UJAAdC5DCKLJ2qAp0VpJOjg8VOX1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.24.tgz", + "integrity": "sha512-8M5nlcWwYt881x83COP2odq5vgf+NgwJh+RMd4LRSv8JI1pxwDfgrDJOERrjTgfC9J5Z0vnQX4b1pdN914e0Zw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/installations-types": "0.5.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.5.tgz", + "integrity": "sha512-e9UYcju3puDl1vdrcKIi5dExzHLameOT/Tc61Q48PYwxtsM1NzZh/ikGbdBQTsbRgg0EMZqdPr0/m5ODUBobrg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.2.tgz", + "integrity": "sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.3.tgz", + "integrity": "sha512-nRXPAp+z0kA3KziJtRY9kktHECvRwaaONzHvislFgllhCwJMscW7icWNQA5IjyP8Uc2+GS/4px8Uz6rx6ftTag==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.30", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.30.tgz", + "integrity": "sha512-m1DPT2ET5x0qYqXLoHwKMAyf1mgZZOhuoE6H6/H4OPsp8zSxMIDEFPaW7MaffPJsCA5J7kHzo+BHVNXK4ZoV+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/messaging": "0.13.3", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.6.tgz", + "integrity": "sha512-MVzvkKe2V4H2dHu5oOxRfeKQcfTwWmCgnzsC4V1q3ixun5iiL8riCZ2qI35rDhCL4glPGiu0jHxDbpVNuTKfow==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.14.tgz", + "integrity": "sha512-9PH1XEZVHErxGdbXluvGz4Uyjw4W955H8v7Mv9rHIybRrg5F2Ac2tvf5+mSf5EtnxWNmxrD2WLnvMFaZP4sMrQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.27.tgz", + "integrity": "sha512-O/ozTf/EbChN94Pk7bd1eUC0PAC36726AwsaiJyC0bZzSBWfLGSWASGPH5pebUWXQPJtGxIJYRkkbX5l0Qa+Hw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/performance": "0.7.14", + "@firebase/performance-types": "0.2.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.5.tgz", + "integrity": "sha512-PRzOgB+/M+6AKlEkY8a9xy5Ff5SfZPIh4iShXhn2WAcL/euSbK7bdLqWysHduunNJhGFsXa22Ag3ixqL6rQtYg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.10.0.tgz", + "integrity": "sha512-lmVTu4jNvOmnrbPPDjhHpb2BdqiP/bwpQtpLSEaLKsYZ+srS6pS6Wfj5Ng1Dbon/HnpPA7Ph5gkI7l1ageGweQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.31.tgz", + "integrity": "sha512-dFLula2ecm74Zbed5bWMgnDNh3djFFqn9DjJYgMxoqrlJZjT0MNWnaQQXpkmw2WE9GzyIkaYS7HuxxLW4Fo8NQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/remote-config": "0.10.0", + "@firebase/remote-config-types": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.2.tgz", + "integrity": "sha512-i8k1omVfoAnaT1ZPv2FFjxMZrATYsO/GnFgHEj5+7fAJLzi8wLnGGv1WK06oEAD6ltrWXSO1HzeNyajn/NjMWw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.6.tgz", + "integrity": "sha512-9WjohxpjbfcySjCp8kIbd1ZhtmCQFQLqPtDQ+wmZ5PVXimTDjDsSx/RZjwwK89MjCTXB9fCdmvZkJxplZn4W1A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.6.tgz", + "integrity": "sha512-te0IsqX1bM+OZCJF7jNSKXcMqCA2yYmpshRHjX4axy0lI/P9ZP9GLjEZMVL8wrjUWF4e3cZxMZTbck3Zd5E1hA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/storage": "0.14.6", + "@firebase/storage-types": "0.8.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=24.12.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.5.tgz", + "integrity": "sha512-GEDs5P+rNUfNcS+wxIdOLAHficife2YXtvTJnyi3ssrX11AAtBg+nDUdbYh8vuBzWehVQZPmztGUUnud4L+4yg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.3.tgz", + "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.7.tgz", + "integrity": "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@grpc/proto-loader/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.10.tgz", + "integrity": "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.5.tgz", + "integrity": "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.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.6", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.6.tgz", + "integrity": "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.10", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.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/figures": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.10.tgz", + "integrity": "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.1.tgz", + "integrity": "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==", + "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/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@msw/url": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@msw/url/-/url-0.1.2.tgz", + "integrity": "sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.45.10", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.10.tgz", + "integrity": "sha512-pGZkvxzv6PZAQCpZHuu+TRs/4U8GWlClRToQETz4MHwwnq0dSxS279xRQ2StBVPpwXxTKtXpLHeK6MGSuxh8uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/until": "^3.0.1", + "@types/debug": "^4.1.13", + "debug": "^4.4.3", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "rettime": "^0.11.12" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@open-draft/until": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", + "integrity": "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/browser-instrumentation": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/browser-instrumentation/-/browser-instrumentation-0.6.0.tgz", + "integrity": "sha512-ZYi3WaiCix1D8K6SY5y+i9zvo0lWsnd/tKKnk5PaqWLLxUhVl0gud8G79RJZ3NApDZvQ57PAvNC7povMHIj7jw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/core": "^2.9.0", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/semantic-conventions": "^1.43.0", + "web-vitals": "^5.3.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.1" + } + }, + "node_modules/@opentelemetry/browser-instrumentation/node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.220.0.tgz", + "integrity": "sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/sdk-logs": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.220.0.tgz", + "integrity": "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-exporter-base": "0.220.0", + "@opentelemetry/otlp-transformer": "0.220.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-document-load/-/instrumentation-document-load-0.65.0.tgz", + "integrity": "sha512-xAhNXUIywjqmaSkpimsuUpRr0QYebMyTn0IJS9wFWYVLyH+2Q5IYWYys4IrT60foeyf2gyFmRBQyHupblNdaMA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.23.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.220.0.tgz", + "integrity": "sha512-9LW+zgvKK6kqojB0GVqbOCRGBUWiatHPmRTusjSVdwYCImIgJRrJTKu6WhjHdejJSjRS/mGsw/w4ETZBqav3kQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.9.0.tgz", + "integrity": "sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.220.0.tgz", + "integrity": "sha512-9FTY+kT3z5D8f7dC+Tubd4oJTu6tOGlQmws5J8e7DLMeIUwpWmLquRFV2BuUShiAPxTMGqqsZnuT3CVVbTu0JA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/sdk-trace-web": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.9.0.tgz", + "integrity": "sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz", + "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/otlp-transformer": "0.220.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz", + "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz", + "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@inquirer/type": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.1.tgz", - "integrity": "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "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==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">=6.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.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==", - "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==", - "license": "MIT", + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.10.0.tgz", + "integrity": "sha512-6WqXdWSlBH0GNjDhv1gg6SLVtMtIBVhLaWqd1hiKSXS8meXXP13sVPQoYFHz1uVo0feUTUCdP9vMS54KflisAw==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" + "@opentelemetry/core": "2.10.0", + "@opentelemetry/sdk-trace-base": "2.10.0" }, "engines": { - "node": ">= 18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "keyv": "^5.6.0" + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@msw/url": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@msw/url/-/url-0.1.2.tgz", - "integrity": "sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==", - "dev": true, - "license": "MIT" + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } }, - "node_modules/@mswjs/interceptors": { - "version": "0.45.10", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.10.tgz", - "integrity": "sha512-pGZkvxzv6PZAQCpZHuu+TRs/4U8GWlClRToQETz4MHwwnq0dSxS279xRQ2StBVPpwXxTKtXpLHeK6MGSuxh8uA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", "dependencies": { - "@open-draft/until": "^3.0.1", - "@types/debug": "^4.1.13", - "debug": "^4.4.3", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "rettime": "^0.11.12" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=22" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@open-draft/until": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", - "integrity": "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" } }, "node_modules/@oxc-project/types": { @@ -938,6 +2430,63 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -1881,7 +3430,6 @@ "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2282,9 +3830,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2323,6 +3869,12 @@ "node": ">=12" } }, + "node_modules/backend": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/backend/-/backend-0.0.0.tgz", + "integrity": "sha512-Fq2aG5+zmmsKv2Dhm3ijAU5spnKOb5ldJlnnC/Vhk6n8In6zaq9eCPBMRiz2j94P/r84QEaBmtwh9tjaDPiQqg==", + "license": "BSD" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2448,6 +4000,12 @@ "node": ">=18" } }, + "node_modules/cjs-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", + "license": "MIT" + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2491,6 +4049,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2579,7 +4155,6 @@ "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" @@ -2685,7 +4260,6 @@ "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" @@ -2973,6 +4547,18 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3017,6 +4603,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-13.3.0.tgz", + "integrity": "sha512-hq5LkmCTAhjlma3WVa7k+e/g7afXQ+h/CBcUZ8vp6HO4FySsNHRQ4lnn4CcUbQd+pFaQyZof77I64e4pqc3yPw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "3.1.0", + "@firebase/analytics": "0.10.25", + "@firebase/analytics-compat": "0.2.31", + "@firebase/app": "0.16.6", + "@firebase/app-check": "0.13.1", + "@firebase/app-check-compat": "0.4.7", + "@firebase/app-compat": "0.5.22", + "@firebase/app-types": "0.9.6", + "@firebase/auth": "1.13.10", + "@firebase/auth-compat": "0.6.15", + "@firebase/crashlytics": "0.1.1", + "@firebase/data-connect": "0.7.4", + "@firebase/database": "1.1.6", + "@firebase/database-compat": "2.1.8", + "@firebase/firestore": "4.20.0", + "@firebase/firestore-compat": "0.4.18", + "@firebase/functions": "0.14.0", + "@firebase/functions-compat": "0.5.0", + "@firebase/installations": "0.6.24", + "@firebase/installations-compat": "0.2.24", + "@firebase/messaging": "0.13.3", + "@firebase/messaging-compat": "0.2.30", + "@firebase/performance": "0.7.14", + "@firebase/performance-compat": "0.2.27", + "@firebase/remote-config": "0.10.0", + "@firebase/remote-config-compat": "0.2.31", + "@firebase/storage": "0.14.6", + "@firebase/storage-compat": "0.4.6", + "@firebase/util": "1.15.3" + } + }, "node_modules/flat-cache": { "version": "6.1.23", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", @@ -3064,7 +4687,6 @@ "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.*" @@ -3176,6 +4798,18 @@ "node": "^22.13.0 || >=24.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3186,6 +4820,26 @@ "node": ">= 4" } }, + "node_modules/import-in-the-middle": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.6.0.tgz", + "integrity": "sha512-xRU7hI24S2FhucXtuZXn8EpcOjfVP6iiEQyR0USayPeaOroEmSsNlqnl95866iRdRtkHrd6Q/Z4D14R60au0+A==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.4", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3216,6 +4870,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3645,6 +5308,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3708,26 +5383,32 @@ } }, "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.8.tgz", + "integrity": "sha512-nFQuYSTVrGvysVBps5Ih+0BdnYFN/Cj3C816r6+p7yK+FHrVLGE4O4N9AFwykrlNlvM2HYt+Rad1Uiro6h88Ig==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "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/msw": { @@ -4013,6 +5694,29 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4043,6 +5747,15 @@ "dev": true, "license": "MIT" }, + "node_modules/re2js": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.10.0.tgz", + "integrity": "sha512-/P6FNnLbHjXJMRF1dPoZoVTyHvFcuVhbDpzml9DPMlbeErYlZ18GueAaG9GhYhAm/9AN7i7Y6crmDrLX0DeJ4Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -4116,6 +5829,15 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4126,6 +5848,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/rettime": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", @@ -4166,6 +5901,26 @@ "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4450,6 +6205,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4531,7 +6292,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, "license": "MIT" }, "node_modules/until-async": { @@ -4768,6 +6528,12 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -4778,6 +6544,29 @@ "node": ">=20" } }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -4912,7 +6701,6 @@ "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" diff --git a/frontend/package.json b/frontend/package.json index 6908169..e8fb6b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "backend": "^0.0.0", + "firebase": "^13.2.0", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -45,5 +47,8 @@ }, "allowScripts": { "fsevents@2.3.3": true + }, + "overrides": { + "minimatch": "^11.0.1" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3f02db..abb729e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,40 +1,43 @@ -import { useState } from "react"; import "./App.css"; -import HomePage from "./pages/homePage/HomePage"; -import CategoryPage from "./pages/categoryPage/CategoryPage"; -import PcBuilderPage from "./pages/pcBuilderPage/PcBuilderPage"; -import CommunityPage from "./pages/communityPage/CommunityPage"; +import HomePage from "./pages/homePage/homePage"; +import CategoryPage from "./pages/categoryPage/categoryPage"; +import PcBuilderPage from "./pages/pcBuilderPage/pcBuilderPage"; +import CommunityPage from "./pages/communityPage/communityPage"; import NavBar from "./components/pageSections/Header"; import Footer from "./components/pageSections/Footer"; import BackToTopButton from "./components/buttons/BackToTopButton"; -import { Routes, Route, useParams } from "react-router"; +import LoginPage from "./pages/loginPage/loginPage"; +import AccountPage from "./pages/accountPage/accountPage"; +import { Routes, Route, useLocation, useParams } from "react-router"; function CategoryRoute() { const { category } = useParams<{ category: string }>(); return ; } -function App() { - const [search, setSearch] = useState(""); +function BuilderRoute() { + const location = useLocation(); + return ; +} +function App() { return (
- +
} /> - } - /> + } /> } /> - } /> + } /> } /> + } /> + } /> 404 - Page not found

} />
@@ -43,5 +46,4 @@ function App() {
); } - export default App; diff --git a/frontend/src/api/builds.ts b/frontend/src/api/builds.ts index 0fd5272..d136f8a 100644 --- a/frontend/src/api/builds.ts +++ b/frontend/src/api/builds.ts @@ -1,3 +1,6 @@ +import type { PcPart } from "@/types/types"; +import { graphql } from "./graphql"; + export type SavedBuild = { id: string; tittel: string; @@ -12,33 +15,25 @@ export type SavedBuild = { }; export async function publishBuild(input: { + id?: string; tittel: string; comment: string; partIds: string[]; }): Promise { - const response = await fetch("/api/graphql", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: `mutation PublishBuild($input: PublishBuildInput!) { - publishBuild(input: $input) { id } - }`, - variables: { input }, - }), - }); - const result: { - data?: { publishBuild: { id: string } }; - errors?: { message: string }[]; - } = await response.json().catch(() => ({})); - if (result.errors?.length) { - throw new Error(result.errors.map((error) => error.message).join(", ")); - } - if (!response.ok || !result.data?.publishBuild) { - throw new Error( - `Could not publish build (HTTP ${response.status}). Check that the backend is updated and running.`, - ); - } - return result.data.publishBuild.id; + const result = await graphql<{ + publishBuild: { id: string }; + }>( + ` + mutation PublishBuild($input: PublishBuildInput!) { + publishBuild(input: $input) { + id + } + } + `, + { input }, + ); + + return result.publishBuild.id; } export async function getBuilds( @@ -70,3 +65,121 @@ export async function getBuilds( } return result.data.builds; } + +export async function savePrivateBuild(input: { + id?: string; + tittel: string; + comment: string; + partIds: string[]; +}): Promise { + const result = await graphql<{ savePrivateBuild: { id: string } }>( + ` + mutation SavePrivateBuild($input: PublishBuildInput!) { + savePrivateBuild(input: $input) { + id + } + } + `, + { input }, + ); + + return result.savePrivateBuild.id; +} + +export type AccountBuild = SavedBuild & { + isPublished: boolean; +}; + +export async function getMyBuilds(offset = 0): Promise { + const result = await graphql<{ myBuilds: AccountBuild[] }>( + ` + query MyBuilds($offset: Int!) { + myBuilds(limit: 21, offset: $offset) { + id + tittel + comment + createdAt + isPublished + parts { + id + name + category + priceEur + } + } + } + `, + { offset }, + ); + + return result.myBuilds; +} + +export async function deleteBuild(id: string): Promise { + await graphql<{ deleteBuild: boolean }>( + ` + mutation DeleteBuild($id: ID!) { + deleteBuild(id: $id) + } + `, + { id }, + ); +} + +export type OpenedBuild = Omit & { parts: PcPart[] }; + +export async function getMyBuild(id: string): Promise { + const result = await graphql<{ + myBuild: + | (Omit & { + parts: { + id: string; + name: string; + category: PcPart["category"]; + manufacturer: string | null; + priceEur: number | null; + imageUrl: string | null; + specs: unknown; + }[]; + }) + | null; + }>( + ` + query OpenMyBuild($id: ID!) { + myBuild(id: $id) { + id + tittel + comment + createdAt + isPublished + parts { + id + name + category + manufacturer + priceEur + imageUrl + specs + } + } + } + `, + { id }, + ); + if (!result.myBuild) return null; + return { + ...result.myBuild, + parts: result.myBuild.parts.map( + (part) => + ({ + id: part.id, + name: part.name, + category: part.category, + manufacturer: part.manufacturer ?? "Unknown", + price_eur: part.priceEur, + image_url: part.imageUrl ? `/api${part.imageUrl}` : "", + details: part.specs ?? {}, + }) as PcPart, + ), + }; +} diff --git a/frontend/src/api/graphql.ts b/frontend/src/api/graphql.ts new file mode 100644 index 0000000..e699333 --- /dev/null +++ b/frontend/src/api/graphql.ts @@ -0,0 +1,33 @@ +import { auth } from "@/lib/firebase"; + +export async function graphql( + query: string, + variables: Record = {}, +): Promise { + await auth.authStateReady(); + const token = await auth.currentUser?.getIdToken(); + + const response = await fetch("/api/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }); + + const result: { + data?: T; + errors?: { message: string }[]; + } = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message).join(", ")); + } + + if (!response.ok || !result.data) { + throw new Error(`Request failed (HTTP ${response.status}).`); + } + + return result.data; +} \ No newline at end of file diff --git a/frontend/src/api/parts.ts b/frontend/src/api/parts.ts index 53e0313..4a1a0c4 100644 --- a/frontend/src/api/parts.ts +++ b/frontend/src/api/parts.ts @@ -15,7 +15,6 @@ type ApiPart = { export type PartsOptions = { search?: string; category?: PcPart["category"]; - manufacturers?: string[]; sortBy?: "NAME" | "PRICE" | "MANUFACTURER" | "CATEGORY"; sortDirection?: "ASC" | "DESC"; first?: number; @@ -35,7 +34,6 @@ export async function getParts( { search = "", category, - manufacturers, sortBy = "NAME", sortDirection = "ASC", first = 20, @@ -52,7 +50,6 @@ export async function getParts( query GetParts( $search: String $category: Category - $manufacturers: [String!] $sortBy: SortField! $sortDirection: SortDirection! $first: Int! @@ -63,7 +60,6 @@ export async function getParts( after: $after search: $search category: $category - manufacturers: $manufacturers sortBy: $sortBy sortDirection: $sortDirection @@ -76,7 +72,6 @@ export async function getParts( manufacturer priceEur imageUrl - specs } } totalCount @@ -90,7 +85,6 @@ export async function getParts( variables: { search: search.trim() || null, category: category ?? null, - manufacturers: manufacturers?.length ? manufacturers : null, sortBy, sortDirection, first, @@ -142,3 +136,45 @@ export async function getParts( pageInfo: connection.pageInfo, }; } + +export async function getPartSpecifications( + id: string, + signal?: AbortSignal, +): Promise { + const response = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ + query: ` + query GetPartSpecifications($id: ID!) { + part(id: $id) { + specs + } + } + `, + variables: { id }, + }), + }) + + if (!response.ok) { + throw new Error(`Could not load specifications (HTTP ${response.status}).`) + } + + const result: { + data?: { part: { specs: PcPart['details'] } | null } + errors?: { message: string }[] + } = await response.json() + + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message).join(', ')) + } + if (!result.data) { + throw new Error('The server returned no data.') + } + if (!result.data.part) { + throw new Error('Part not found.') + } + + return result.data.part.specs +} diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx new file mode 100644 index 0000000..acfe6a8 --- /dev/null +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -0,0 +1,128 @@ +import type { PcPart } from "@/types/types"; +import { useBuild } from "@/context/BuildContext"; +import { useEffect, useId, useRef } from "react"; +import { flushSync } from "react-dom"; +import { ArrowRight, X } from "lucide-react"; +import SuccessNotification from "./SuccessNotification"; + +type AddToBuildFeedbackProps = { + part: PcPart; + stage: "confirm" | "loading" | "success"; + onReplace: () => Promise; + onClose: () => void; +}; + +export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: AddToBuildFeedbackProps) { + const dialogRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const { parts } = useBuild(); + const savedPart = parts[part.category]; + const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; + const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; + + useEffect(() => { + dialogRef.current?.showModal(); + }, [stage]); + + function closeFeedback() { + dialogRef.current?.close(); + } + + function goToBuild() { + // Remove the feedback before the browser saves this page for Back navigation. + flushSync(() => onClose()); + window.location.href = "/project2/builder"; + } + + if (stage === "success") { + return ( + + ); + } + + return ( + + + +
+
+
+

+ {stage === "confirm" ? "Replace component?" : "Adding component…"} +

+

+ {stage === "confirm" + ? `You have already selected ${savedPart?.name}. Would you like to replace it with ${part.name}?` + : `Adding ${part.name} to your build.`} +

+
+
+ + {stage === "confirm" && ( +
+
+ {savedPart?.image_url && ( +
+ {savedPart.name} +
+ )} +
+

Current component

+

{savedPart?.name}

+
+
+
+ )} + {stage === "confirm" && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/AllPartsPreview.tsx b/frontend/src/components/AllPartsPreview.tsx index 5e65be9..21b531d 100644 --- a/frontend/src/components/AllPartsPreview.tsx +++ b/frontend/src/components/AllPartsPreview.tsx @@ -1,9 +1,12 @@ import { useParts } from "@/hooks/useParts"; import PcPartCard from "@/components/PcPartCard"; +import PcPartCardSkeleton from "@/components/PcPartCardSkeleton"; import ViewSelector, { type ViewType } from "@/components/ViewSelector"; import type { PcPart } from "@/types/types"; import type { PartsOptions } from "@/api/parts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import MobileComponentDetailPage from "./MobileComponentDetailPage"; +import DesktopComponentDetailPage from "./DesktopComponentDetailPage"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; type AllPartsPreviewProps = PartsOptions & { featured?: boolean; @@ -11,6 +14,18 @@ type AllPartsPreviewProps = PartsOptions & { viewenabled?: boolean; }; +// Compare summary values, not the new objects returned by each API request. +const PreviewPartCard = memo(function PreviewPartCard({ + onShowDetails, + ...summary +}: Omit & { + onShowDetails: (part: PcPart) => void; +}) { + // Catalogue results omit specifications; card actions fetch them when needed. + const part = { ...summary, details: {} } as PcPart; + return ; +}); + export default function AllPartsPreview({ featured = false, defaultview = "4-grid-view", @@ -21,7 +36,6 @@ export default function AllPartsPreview({ return ( void; }) { - const [cursors, setCursors] = useState([]); - const [allParts, setAllParts] = useState([]); + const filterKey = JSON.stringify(options); + const [pagination, setPagination] = useState({ + filterKey, + after: undefined as string | undefined, + parts: [] as PcPart[], + }); + // Reset pagination without unmounting cards that may match the new filters. + if (pagination.filterKey !== filterKey) { + setPagination({ filterKey, after: undefined, parts: [] }); + } const loadMoreRef = useRef(null); - const requestedCursorRef = useRef(null); const first = options.first ?? 40; + const dialogRef = useRef(null); + const [selectedPart, setSelectedPart] = useState(null); + const [isDesktop, setIsDesktop] = useState(false); + + const handleShowDetails = useCallback((part: PcPart) => { + setIsDesktop(window.matchMedia("(min-width: 48rem)").matches); + setSelectedPart(part); + }, []); + + useEffect(() => { + if (!selectedPart) return; + dialogRef.current?.showModal(); + const query = window.matchMedia("(min-width: 48rem)"); + const handleChange = (event: MediaQueryListEvent) => + setIsDesktop(event.matches); + query.addEventListener("change", handleChange); + return () => query.removeEventListener("change", handleChange); + }, [selectedPart]); + + useEffect(() => { + if (selectedPart) dialogRef.current?.scrollTo(0, 0); + }, [isDesktop, selectedPart]); + + function closeDetails() { + dialogRef.current?.close(); + setSelectedPart(null); + } - const { parts, totalCount, pageInfo, loading, error } = useParts({ + const { parts, totalCount, pageInfo, loading, error, retry } = useParts({ ...options, first, - after: cursors.at(-1), + after: pagination.filterKey === filterKey ? pagination.after : undefined, }); - const visibleParts = [...allParts, ...parts]; + const allParts = pagination.parts; + const [lastResult, setLastResult] = useState({ allParts, parts }); + if ( + !loading && + !error && + pagination.filterKey === filterKey && + (lastResult.parts !== parts || lastResult.allParts !== allParts) + ) { + setLastResult({ allParts, parts }); + } + // Keep the last completed list mounted while the next request is pending. + const visibleParts = loading + ? [...lastResult.allParts, ...lastResult.parts] + : [...allParts, ...parts]; + const showSkeletons = + loading && (visibleParts.length === 0 || Boolean(pagination.after)); + const skeletonCount = Math.min( + first, + viewType === "4-grid-view" ? 8 : viewType === "2-grid-view" ? 4 : 2, + ); const handleLoadMore = useCallback(() => { const cursor = pageInfo?.endCursor; @@ -63,15 +130,17 @@ function PartsResults({ error || !pageInfo?.hasNextPage || !cursor || - requestedCursorRef.current === cursor + pagination.after === cursor ) { return; } - requestedCursorRef.current = cursor; - setAllParts((previous) => [...previous, ...parts]); - setCursors((previous) => [...previous, cursor]); - }, [loading, error, pageInfo, parts]); + setPagination((previous) => + previous.after === cursor + ? previous + : { filterKey, after: cursor, parts: [...previous.parts, ...parts] }, + ); + }, [loading, error, pageInfo, parts, pagination.after, filterKey]); function getStyle() { switch (viewType) { @@ -114,19 +183,80 @@ function PartsResults({ )} - {!featured && !loading && !error && ( -

+ {!featured && ( +

Showing {visibleParts.length} of {totalCount} components

)} -
+
{visibleParts.map((part) => ( - + ))} + {showSkeletons && + Array.from({ length: skeletonCount }, (_, index) => ( + + ))}
- {loading &&

Loading parts...

} + { + if ( + event.target === event.currentTarget && + !event.currentTarget.open + ) { + setSelectedPart(null); + } + }} + onSubmit={(event) => { + if ( + event.target instanceof HTMLFormElement && + event.target.method === "dialog" && + event.target.closest("dialog") === event.currentTarget + ) { + event.preventDefault(); + closeDetails(); + } + }} + onClick={(e) => { + if (e.target === e.currentTarget) closeDetails(); + }} + > + {selectedPart && + (isDesktop ? ( + + ) : ( + + ))} + + + {loading && ( +

+ Loading parts... +

+ )} {error &&

{error}

} {!loading && !error && visibleParts.length === 0 && ( @@ -137,17 +267,17 @@ function PartsResults({ ))} diff --git a/frontend/src/components/PcPartCard.tsx b/frontend/src/components/PcPartCard.tsx index fd9837a..7432a86 100644 --- a/frontend/src/components/PcPartCard.tsx +++ b/frontend/src/components/PcPartCard.tsx @@ -1,107 +1,108 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import type { PcPart } from "@/types/types"; -import MobileComponentDetailPage from "./MobileComponentDetailPage"; -import DesktopComponentDetailPage from "./DesktopComponentDetailPage"; +import { getPartSpecifications } from "@/api/parts"; import AddToBuildButton from "./buttons/AddToBuildButton"; import Button from "@/components/buttons/Button"; +import PcPartCardLayout from "./PcPartCardLayout"; -export default function PcPartCard({ part }: { part: PcPart }) { +export default function PcPartCard({ + part, + onShowDetails, +}: { + part: PcPart; + onShowDetails: (part: PcPart) => void; +}) { const [failedImage, setFailedImage] = useState(""); - const [detailSession, setDetailSession] = useState(0); - const dialogRef = useRef(null); + // Keep fetched specifications in this card even after the detail view closes. + // They are discarded when the card unmounts, for example when filtered out. + const [specifications, setSpecifications] = useState< + PcPart["details"] | null + >(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const [detailError, setDetailError] = useState(""); + const detailRequest = useRef(null); const imageUrl = part.image_url?.trim() ?? ""; - const [isDesktop, setIsDesktop] = useState( - () => window.matchMedia("(min-width: 48rem)").matches, - ); - - useEffect(() => { - const query = window.matchMedia("(min-width: 48rem)"); - const handleChange = (event: MediaQueryListEvent) => { - setIsDesktop(event.matches); - }; + // Combine saved specifications with the latest summary (including price). + // This creates a new object without changing the part prop. + const completePart = specifications + ? ({ ...part, details: specifications } as PcPart) + : part; - query.addEventListener("change", handleChange); - return () => query.removeEventListener("change", handleChange); - }, []); + // Cancel an unfinished request when this card leaves the page. + useEffect(() => () => detailRequest.current?.abort(), []); - useEffect(() => { - dialogRef.current?.scrollTo(0, 0); - }, [isDesktop]); + async function handleShowDetails() { + // Ignore repeated clicks while this card is already fetching details. + if (detailRequest.current) return; + setDetailError(""); + // Reuse specifications saved here or already supplied with the part. + if (specifications || Object.keys(part.details).length > 0) { + onShowDetails(completePart); + return; + } - function closeDetails() { - dialogRef.current?.close(); - // Remount the details for explicit closes; Escape keeps the current state. - setDetailSession((previous) => previous + 1); + const controller = new AbortController(); + detailRequest.current = controller; + setLoadingDetails(true); + try { + const details = await getPartSpecifications(part.id, controller.signal); + if (controller.signal.aborted) return; + // Save details for later actions and give the parent the complete part. + // Use the fetched value directly because setting state takes effect next render. + setSpecifications(details); + onShowDetails({ ...part, details } as PcPart); + } catch { + if (!controller.signal.aborted) { + setDetailError("Could not load component details. Please try again."); + } + } finally { + // Allow another click, including a retry if fetching failed. + detailRequest.current = null; + if (!controller.signal.aborted) setLoadingDetails(false); + } } return ( - <> - {/* The card layout follows the width of its grid cell, not the screen. */} -
-
- {imageUrl && imageUrl !== failedImage ? ( - {part.name} setFailedImage(imageUrl)} - /> - ) : ( -

- Image unavailable -

- )} - -
-

- {part.name} -

-

{part.category}

-

- {part.price_eur === null - ? "Price unavailable" - : `€ ${part.price_eur.toFixed(2)}`} -

- - -
-
-
- - { - if ( - event.target instanceof HTMLFormElement && - event.target.method === "dialog" - ) { - event.preventDefault(); - closeDetails(); - } - }} - onClick={(e) => { - if (e.target === e.currentTarget) closeDetails(); - }} - > - {isDesktop ? ( - + setFailedImage(imageUrl)} + /> ) : ( - - )} - - +

Image unavailable

+ ) + } + name={part.name} + nameTitle={part.name} + category={part.category} + price={ + part.price_eur === null + ? "Price unavailable" + : `€ ${part.price_eur.toFixed(2)}` + } + detailsAction={ + + } + error={detailError &&

{detailError}

} + buildAction={ + + } + /> ); } diff --git a/frontend/src/components/PcPartCardLayout.tsx b/frontend/src/components/PcPartCardLayout.tsx new file mode 100644 index 0000000..2abc8f9 --- /dev/null +++ b/frontend/src/components/PcPartCardLayout.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +type PcPartCardLayoutProps = { + image: ReactNode; + name: ReactNode; + nameTitle?: string; + category: ReactNode; + price: ReactNode; + detailsAction: ReactNode; + buildAction: ReactNode; + error?: ReactNode; + skeleton?: boolean; +}; +//Layot for the PcPartCard and the PcPartCardSkeleton. +export default function PcPartCardLayout({ + image, + name, + nameTitle, + category, + price, + detailsAction, + buildAction, + error, + skeleton = false, +}: PcPartCardLayoutProps) { + return ( +
+
+
+ {image} +
+
+

+ {name} +

+

{category}

+

{price}

+
{detailsAction}
+ {error} + {buildAction} +
+
+
+ ); +} diff --git a/frontend/src/components/PcPartCardSkeleton.tsx b/frontend/src/components/PcPartCardSkeleton.tsx new file mode 100644 index 0000000..5844db6 --- /dev/null +++ b/frontend/src/components/PcPartCardSkeleton.tsx @@ -0,0 +1,21 @@ +import PcPartCardLayout from "./PcPartCardLayout"; + +export default function PcPartCardSkeleton() { + const actionPlaceholder = ( +
+ Loading +
+ ); + + return ( + } + category={} + price={} + detailsAction={actionPlaceholder} + buildAction={actionPlaceholder} + /> + ); +} diff --git a/frontend/src/components/SearchComponent.tsx b/frontend/src/components/SearchComponent.tsx new file mode 100644 index 0000000..f8d559a --- /dev/null +++ b/frontend/src/components/SearchComponent.tsx @@ -0,0 +1,57 @@ +import { useRef } from "react"; +import { Search, X } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +type SearchComponentProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; + label?: string; + variant?: "default" | "light"; + className?: string; +}; + +export default function SearchComponent({ + value, + onChange, + placeholder = "Search components or manufacturers...", + label = "Search components", + variant = "default", + className, +}: SearchComponentProps) { + const inputRef = useRef(null); + + return ( +
+
+ ); +} diff --git a/frontend/src/components/SuccessNotification.tsx b/frontend/src/components/SuccessNotification.tsx new file mode 100644 index 0000000..ee90cc0 --- /dev/null +++ b/frontend/src/components/SuccessNotification.tsx @@ -0,0 +1,100 @@ +import { useEffect, useId, useRef } from "react"; +import { CircleCheck, X } from "lucide-react"; + +type SuccessNotificationProps = { + title: string; + description: string; + onClose: () => void; + action?: { label: string; onClick: () => void }; +}; + +export default function SuccessNotification({ + title, + description, + onClose, + action, +}: SuccessNotificationProps) { + const notificationRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + + useEffect(() => { + const notification = notificationRef.current; + if (!notification) return; + // Stay above the page without locking scrolling or moving keyboard focus. + notification.showPopover(); + + let exitAnimation: Animation | undefined; + const timer = window.setTimeout(() => { + if (!notification.matches(":popover-open")) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + notification.hidePopover(); + return; + } + exitAnimation = notification.animate( + [ + { transform: "translateX(0)", opacity: 1 }, + { transform: "translateX(calc(100% + 1rem))", opacity: 0 }, + ], + { duration: 250, easing: "ease-in", fill: "forwards" }, + ); + exitAnimation.onfinish = () => notification.hidePopover(); + }, 5000); + return () => { + window.clearTimeout(timer); + exitAnimation?.cancel(); + }; + }, []); + + return ( +
{ + if (event.newState === "closed") onClose(); + }} + aria-labelledby={titleId} + aria-describedby={descriptionId} + className="fixed right-4 left-auto top-20 bottom-auto m-0 h-fit max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-sm overflow-y-auto rounded-2xl border border-surface bg-background p-0 text-white shadow-lg shadow-black/20 backdrop:bg-transparent motion-safe:animate-[feedback-slide-in_250ms_ease-out]" + > + +
+
+ + ); +} diff --git a/frontend/src/components/buttons/AddToBuildButton.tsx b/frontend/src/components/buttons/AddToBuildButton.tsx index a3f5582..8107348 100644 --- a/frontend/src/components/buttons/AddToBuildButton.tsx +++ b/frontend/src/components/buttons/AddToBuildButton.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import type { PcPart } from "@/types/types"; -import { useBuild } from "@/context/buildContext"; -import { useNavigate } from "react-router"; +import { useBuild } from "@/context/BuildContext"; import { twMerge } from "tailwind-merge"; import Button from "./Button"; +import AddToBuildFeedback from "../AddToBuildFeedback"; +import { getPartSpecifications } from "@/api/parts"; type AddToBuildButtonProps = { part: PcPart; @@ -16,27 +18,65 @@ export default function AddToBuildButton({ children, className, }: AddToBuildButtonProps) { - const navigate = useNavigate(); const { parts, addPart, removePart } = useBuild(); + const [showFeedback, setShowFeedback] = useState(false); const inBuild = parts[part.category]?.id === part.id; + const [stage, setStage] = useState<"confirm" | "loading" | "success">("confirm"); + const adding = useRef(false); + + async function handleAdd(){ //Reuse existing specifications, or fetch them before adding the part. + if (adding.current) return; + adding.current = true; + const loadingTimer = window.setTimeout(() => setStage("loading"), 500); + try { + const hasSpecifications = part.details && Object.keys(part.details).length > 0; + const specifications = hasSpecifications + ? part.details + : await getPartSpecifications(part.id); + const completePart = {...part, details: specifications,} as PcPart; + addPart(completePart); + setStage("success"); + } finally { + window.clearTimeout(loadingTimer); + adding.current = false; + } + } + + async function handleClick(){ + if (adding.current) return; + if (inBuild) { + removePart(part.category); + setShowFeedback(false); + return; + } + setStage("confirm"); + setShowFeedback(true); + if(!parts[part.category]){ + await handleAdd() + } + } return ( - + + {showFeedback && (stage !== "confirm" || parts[part.category]) && ( + setShowFeedback(false)} + /> )} - onClick={() => { - if (inBuild) { - removePart(part.category); - return; - } - addPart(part); - navigate("/builder", { state: { highlight: part.category } }); - }} - > - {children ?? (inBuild ? "Remove from build" : "Add to build")} - + ); } diff --git a/frontend/src/components/buttons/Button.tsx b/frontend/src/components/buttons/Button.tsx index 6404599..fc7983d 100644 --- a/frontend/src/components/buttons/Button.tsx +++ b/frontend/src/components/buttons/Button.tsx @@ -7,6 +7,7 @@ type ButtonProps = { className?: string; onClick?: () => void; type?: "button" | "submit"; + disabled?: boolean; }; export default function Button({ @@ -14,12 +15,14 @@ export default function Button({ className = "", onClick, type = "button", + disabled = false, }: ButtonProps) { - const inactive = !onClick && type !== "submit"; + const inactive = disabled || (!onClick && type !== "submit"); return ( + + + {logoutError && ( +

+ {logoutError} +

+ )} + +
+
+

My builds

+

+ Your private builds and the builds you’ve shared. +

+
+ + +
+ + {notice && ( +

+ {notice} +

+ )} + + {loading && ( +

+

+ )} + + {!loading && error && ( +
+

+ {error} +

+ +
+ )} + + {!loading && !error && ( + <> + {builds.length === 0 ? ( +
+

+ {offset === 0 + ? "Your next build starts here" + : "No more builds"} +

+

+ {offset === 0 + ? "Builds you save or publish will appear here." + : "Go back to see your earlier results."} +

+
+ ) : ( +
+ {builds.slice(0, 20).map((build) => ( +
+ +
+ + {build.isPublished ? "Published" : "Private"} + + + +
+ +

{build.tittel}

+ +
+ + {build.parts.length} components + + +
+ +
+ +
+
+ ))} +
+ )} + +
+ {offset > 0 && ( + + )} + {builds.length > 20 && ( + + )} +
+ + )} + + ); +} diff --git a/frontend/src/pages/accountPage/components/DeleteBuildButton.tsx b/frontend/src/pages/accountPage/components/DeleteBuildButton.tsx new file mode 100644 index 0000000..6771d4f --- /dev/null +++ b/frontend/src/pages/accountPage/components/DeleteBuildButton.tsx @@ -0,0 +1,108 @@ +import { useId, useRef, useState } from "react"; +import { LoaderCircle, Trash2 } from "lucide-react"; +import { deleteBuild } from "@/api/builds"; +import { useSavedBuild } from "@/context/BuildContext"; + +type Props = { id: string; title: string; onDeleted: () => void }; + +export default function DeleteBuildButton({ id, title, onDeleted }: Props) { + const dialogRef = useRef(null); + const cancelRef = useRef(null); + const inFlight = useRef(false); + const headingId = useId(); + const descriptionId = useId(); + const { forgetSavedBuild } = useSavedBuild(); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + + async function confirmDelete() { + if (inFlight.current) return; + inFlight.current = true; + setDeleting(true); + setError(""); + try { + await deleteBuild(id); + forgetSavedBuild(id); + dialogRef.current?.close(); + onDeleted(); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Could not delete build. Please try again.", + ); + } finally { + inFlight.current = false; + setDeleting(false); + } + } + + return ( + <> + + { + if (inFlight.current) event.preventDefault(); + }} + className="m-auto w-[calc(100%-2rem)] max-w-md rounded-xl border border-gray-600 bg-surface p-6 text-white shadow-xl backdrop:bg-black/60" + > +

+ Delete build? +

+

+ Permanently delete{" "} + {title} from + your account and Community? This cannot be undone. +

+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ + ); +} diff --git a/frontend/src/pages/categoryPage/categoryPage.tsx b/frontend/src/pages/categoryPage/categoryPage.tsx index 11d417d..cbb2c1b 100644 --- a/frontend/src/pages/categoryPage/categoryPage.tsx +++ b/frontend/src/pages/categoryPage/categoryPage.tsx @@ -1,39 +1,26 @@ import AllPartsPreview from "../../components/AllPartsPreview"; import { categories } from "@/types/categories"; import FilterComponent from "@/components/FilterComponent"; -import { useState } from "react"; -import { Link } from "react-router"; -import { ArrowLeft } from "lucide-react"; -import type { PartsOptions } from "@/api/parts"; +import { useCatalogParams } from "@/hooks/useCatalogParams"; +import CategorySelection from "@/components/CategorySelection"; type CategoryPageProps = { category?: string; - search?: string; }; -export default function CategoryPage({ - category, - search: initialSearch = "", -}: CategoryPageProps) { +export default function CategoryPage({ category }: CategoryPageProps) { const initialCategory = categories.find((item) => item.value === category); const invalidCategory = category !== undefined && !initialCategory; - const [search, setSearch] = useState(initialSearch); - const [filtOptions, setFiltOptions] = useState({ - category: initialCategory?.value, - }); + const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams( + initialCategory?.value, + ); const selectedCategory = categories.find( (item) => item.value === filtOptions.category, ); return ( -
- -
diff --git a/frontend/src/styles/colors.css b/frontend/src/styles/colors.css index 1dc6fab..6a620ab 100644 --- a/frontend/src/styles/colors.css +++ b/frontend/src/styles/colors.css @@ -3,6 +3,12 @@ --color-surface: #485458; --color-surface-dark: #343e42; --color-primary: #4adea0; - --color-secondary: #a7b3b0; + --color-secondary: #c0cbc8; --color-accent: #ff922b; + + /* Status text colors, at least 4.5:1 on surface and background (WCAG AA). */ + --color-status-incompatible: #ffb7b3; + --color-status-warning: #ffc28c; + --color-status-unknown: #e5d183; + --color-status-compatible: #9be5b4; } diff --git a/frontend/src/test/AllPartsPreview.test.tsx b/frontend/src/test/AllPartsPreview.test.tsx index 2ec2bcf..05fa2d8 100644 --- a/frontend/src/test/AllPartsPreview.test.tsx +++ b/frontend/src/test/AllPartsPreview.test.tsx @@ -1,8 +1,9 @@ import { it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, fireEvent } from "@testing-library/react"; import AllPartsPreview from "@/components/AllPartsPreview"; import { useParts } from "@/hooks/useParts"; import type { PcPart } from "@/types/types"; +import PcPartCard from "@/components/PcPartCard"; // -------------------------------------------------- // Mock IntersectionObserver @@ -50,9 +51,12 @@ vi.mock("@/hooks/useParts", () => ({ })); vi.mock("@/components/PcPartCard", () => ({ - default: ({ part }: { part: PcPart }) => ( -
{part.name}
- ), + default: vi.fn(({ part }: { part: PcPart }) => ( +
+ {part.name} + €{part.price_eur} +
+ )), })); vi.mock("@/components/ViewSelector", () => ({ @@ -106,6 +110,44 @@ const mockParts = [ // Test 1: Search with matching results // -------------------------------------------------- +it("shows loading placeholders, then replaces them with results", () => { + vi.mocked(useParts).mockReturnValue({ + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: true, + retry: vi.fn(), + error: null, + }); + + const { container, rerender } = render( + , + ); + const grid = container.querySelector('[aria-busy="true"]')!; + expect(grid.children).toHaveLength(2); + expect( + Array.from(grid.children).every( + (child) => child.getAttribute("aria-hidden") === "true", + ), + ).toBe(true); + expect(screen.getByRole("status")).toHaveTextContent("Loading parts"); + expect(screen.queryByText("No parts found.")).not.toBeInTheDocument(); + + vi.mocked(useParts).mockReturnValue({ + parts: mockParts.slice(0, 2), + totalCount: 2, + pageInfo: { hasNextPage: false, endCursor: null }, + loading: false, + retry: vi.fn(), + error: null, + }); + rerender(); + expect(grid).toHaveAttribute("aria-busy", "false"); + expect(grid.querySelector('[aria-hidden="true"]')).toBeNull(); + expect(screen.getAllByTestId(/^part-/)).toHaveLength(2); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); + it("displays matching search results", () => { vi.mocked(useParts).mockReturnValue({ parts: mockParts.slice(0, 2), @@ -115,6 +157,7 @@ it("displays matching search results", () => { endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }); @@ -148,6 +191,7 @@ it("shows an empty state when no parts match the search", () => { endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }); @@ -171,23 +215,30 @@ it("shows an empty state when no parts match the search", () => { // Test 3: API error // -------------------------------------------------- -it("shows an error message when the API request fails", () => { - vi.mocked(useParts).mockReturnValue({ - parts: [], - totalCount: 0, - pageInfo: undefined, - loading: false, - error: "Network error", - }); +it.each([false, true])( + "offers retry when a request fails (featured=%s)", + (featured) => { + const retry = vi.fn(); + vi.mocked(useParts).mockReturnValue({ + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: false, + retry, + error: "Network error", + }); - render(); + render(); - // Check that the error message is displayed - expect(screen.getByRole("alert")).toHaveTextContent("Network error"); + // Check that the error message is displayed + expect(screen.getByRole("alert")).toHaveTextContent("Network error"); - // An API error should not be displayed as an empty result - expect(screen.queryByText("No parts found.")).not.toBeInTheDocument(); -}); + // An API error should not be displayed as an empty result + expect(screen.queryByText("No parts found.")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(retry).toHaveBeenCalledTimes(1); + }, +); // -------------------------------------------------- // Test 4: Filtering and sorting @@ -202,6 +253,7 @@ it("displays filtered parts in the requested sort order", () => { endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }); @@ -245,6 +297,7 @@ it("loads the next page without duplicating previous results", () => { endCursor: "cursor-page-2", }, loading: false, + retry: vi.fn(), error: null, }); @@ -254,6 +307,15 @@ it("loads the next page without duplicating previous results", () => { expect(screen.getByText("RTX 4060")).toBeInTheDocument(); expect(screen.getByText("RTX 4070")).toBeInTheDocument(); + vi.mocked(useParts).mockReturnValue({ + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: true, + retry: vi.fn(), + error: null, + }); + // Simulate scrolling to the bottom of the page act(() => { expect(triggerIntersection).toBeDefined(); @@ -261,6 +323,12 @@ it("loads the next page without duplicating previous results", () => { }); // Check that the next page cursor is requested + expect(screen.getAllByTestId(/^part-/)).toHaveLength(2); + const loadingGrid = screen.getByTestId("part-1").parentElement!; + expect(loadingGrid).toHaveAttribute("aria-busy", "true"); + expect( + loadingGrid.querySelectorAll(':scope > [aria-hidden="true"]'), + ).toHaveLength(2); expect(useParts).toHaveBeenCalledWith( expect.objectContaining({ first: 2, @@ -268,6 +336,24 @@ it("loads the next page without duplicating previous results", () => { }), ); + // A failed next-page request preserves earlier cards and retries the same cursor. + const retry = vi.fn(); + vi.mocked(useParts).mockReturnValue({ + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: false, + error: "Network error", + retry, + }); + rerender(); + expect(screen.getAllByTestId(/^part-/)).toHaveLength(2); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(retry).toHaveBeenCalledTimes(1); + expect(useParts).toHaveBeenLastCalledWith( + expect.objectContaining({ after: "cursor-page-2" }), + ); + // Mock the second page vi.mocked(useParts).mockReturnValue({ parts: secondPage, @@ -277,6 +363,7 @@ it("loads the next page without duplicating previous results", () => { endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }); @@ -298,3 +385,102 @@ it("loads the next page without duplicating previous results", () => { // Check the total number of displayed components expect(screen.getByText("Showing 4 of 4 components")).toBeInTheDocument(); }); + +it("reuses unchanged cards across layout changes, filtering and fresh API objects", () => { + const initial = { + parts: mockParts.slice(0, 3), + totalCount: 3, + pageInfo: { hasNextPage: false, endCursor: null }, + loading: false, + retry: vi.fn(), + error: null, + }; + vi.mocked(useParts).mockReturnValue(initial); + const { rerender } = render(); + const retainedCard = screen.getByTestId("part-2"); + vi.mocked(PcPartCard).mockClear(); + + rerender(); + expect(PcPartCard).not.toHaveBeenCalled(); + + vi.mocked(useParts).mockReturnValue({ + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: true, + retry: vi.fn(), + error: null, + }); + rerender(); + expect(screen.getByTestId("part-2")).toBe(retainedCard); + expect(screen.getByRole("status")).toHaveTextContent("Loading parts"); + expect(PcPartCard).not.toHaveBeenCalled(); + + vi.mocked(useParts).mockReturnValue({ + ...initial, + parts: [ + { ...mockParts[2], price_eur: 899 }, + { ...mockParts[1], details: {} } as PcPart, + mockParts[3], + ], + }); + rerender(); + expect(screen.getByTestId("part-2")).toBe(retainedCard); + expect(screen.queryByTestId("part-1")).not.toBeInTheDocument(); + expect(screen.getByTestId("part-3")).toHaveTextContent("€899"); + expect( + screen.getAllByTestId(/^part-/).map((card) => card.dataset.testid), + ).toEqual(["part-3", "part-2", "part-4"]); + expect( + vi.mocked(PcPartCard).mock.calls.map(([props]) => props.part.id), + ).toEqual(["3", "4"]); +}); + +it("resets pagination on filter changes and replaces old results even when empty", () => { + const firstPage = { + parts: mockParts.slice(0, 2), + totalCount: 4, + pageInfo: { hasNextPage: true, endCursor: "next" }, + loading: false, + retry: vi.fn(), + error: null, + }; + const pending = { + parts: [], + totalCount: 0, + pageInfo: undefined, + loading: true, + retry: vi.fn(), + error: null, + }; + vi.mocked(useParts).mockReturnValue(firstPage); + const { rerender } = render(); + vi.mocked(PcPartCard).mockClear(); + vi.mocked(useParts).mockReturnValue(pending); + act(() => triggerIntersection?.()); + expect(PcPartCard).not.toHaveBeenCalled(); + expect(screen.getAllByTestId(/^part-/)).toHaveLength(2); + + vi.mocked(useParts).mockReturnValue({ + ...firstPage, + parts: mockParts.slice(2), + pageInfo: { hasNextPage: false, endCursor: null }, + }); + rerender(); + expect(screen.getAllByTestId(/^part-/)).toHaveLength(4); + expect( + vi.mocked(PcPartCard).mock.calls.map(([props]) => props.part.id), + ).toEqual(["3", "4"]); + + vi.mocked(useParts).mockReturnValue(pending); + rerender(); + expect(useParts).toHaveBeenLastCalledWith( + expect.objectContaining({ after: undefined }), + ); + expect(screen.getAllByTestId(/^part-/)).toHaveLength(4); + + vi.mocked(useParts).mockReturnValue({ ...pending, loading: false }); + rerender(); + expect(screen.queryAllByTestId(/^part-/)).toHaveLength(0); + expect(screen.getByText("No parts found.")).toBeInTheDocument(); +}); diff --git a/frontend/src/test/BuildProvider.test.tsx b/frontend/src/test/BuildProvider.test.tsx new file mode 100644 index 0000000..b69ce6f --- /dev/null +++ b/frontend/src/test/BuildProvider.test.tsx @@ -0,0 +1,188 @@ +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import BuildProvider from "@/context/BuildProvider"; +import { useBuild } from "@/context/BuildContext"; +import type { PcPart } from "@/types/types"; + +const STORAGE_KEY = "pc-builder"; + +// The removal notification uses browser popover APIs that jsdom does not have. +vi.mock("@/components/SuccessNotification", () => ({ default: () => null })); + +const part = (id: string, category: string, name: string) => + ({ id, category, name, price_eur: 100, details: {} }) as unknown as PcPart; +const cpu = part("cpu-1", "CPU", "Test CPU"); +const otherCpu = part("cpu-2", "CPU", "Other CPU"); +const gpu = part("gpu-1", "GPU", "Test GPU"); + +function renderBuild() { + return renderHook(() => useBuild(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +const saved = () => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + +beforeEach(() => localStorage.clear()); +afterEach(() => vi.restoreAllMocks()); + +describe("loading a saved build", () => { + it("starts with an empty build when nothing is saved", () => { + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("restores a saved build", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ CPU: cpu, GPU: gpu })); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({ CPU: cpu, GPU: gpu }); + }); + + it.each([ + ["invalid JSON", "{not json"], + ["an empty string", ""], + ["a truncated value", '{"CPU":{"id":"cpu-1"'], + ["null", "null"], + ])("starts empty when the saved value is %s", (_, value) => { + localStorage.setItem(STORAGE_KEY, value); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it.each([ + ["a string", '"CPU"'], + ["a number", "42"], + ["a boolean", "true"], + ["an array", "[1, 2]"], + ])("starts empty when the saved value is %s", (_, value) => { + localStorage.setItem(STORAGE_KEY, value); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("keeps parts with a missing price", () => { + const noPrice = { ...cpu, price_eur: null }; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ CPU: noPrice })); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({ CPU: noPrice }); + }); + + it.each([ + ["a string instead of a part", "oops"], + ["a part with the wrong category", { ...cpu, category: "GPU" }], + ["a part without an ID", { ...cpu, id: undefined }], + ["a part without a name", { ...cpu, name: 5 }], + ["a part with a text price", { ...cpu, price_eur: "cheap" }], + ["a part with a negative price", { ...cpu, price_eur: -1 }], + ["a part without details", { ...cpu, details: null }], + ])("drops %s but keeps the valid parts", (_, invalid) => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ CPU: invalid, GPU: gpu, Keyboard: gpu }), + ); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({ GPU: gpu }); + }); + + it("starts empty when the browser refuses to read storage", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage is blocked"); + }); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("overwrites corrupted data with the new build", () => { + localStorage.setItem(STORAGE_KEY, "{not json"); + + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + expect(saved()).toEqual({ CPU: cpu }); + }); +}); + +describe("changing the build", () => { + it("saves added parts and keeps one part per category", () => { + const { result } = renderBuild(); + + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + act(() => result.current.addPart(otherCpu)); + + expect(result.current.parts).toEqual({ CPU: otherCpu, GPU: gpu }); + expect(saved()).toEqual({ CPU: otherCpu, GPU: gpu }); + }); + + it("removes a part and saves the change", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + + act(() => result.current.removePart("CPU")); + + expect(result.current.parts).toEqual({ GPU: gpu }); + expect(saved()).toEqual({ GPU: gpu }); + }); + + it("ignores removing a category that is not in the build", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + act(() => result.current.removePart("RAM")); + + expect(result.current.parts).toEqual({ CPU: cpu }); + }); + + it("clears the whole build and the saved copy", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + + act(() => result.current.resetBuild()); + + expect(result.current.parts).toEqual({}); + expect(saved()).toEqual({}); + }); + + it("keeps working when the browser refuses to save", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Quota exceeded"); + }); + + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + expect(result.current.parts).toEqual({ CPU: cpu }); + expect(warn).toHaveBeenCalledWith( + "Could not save the build in this browser.", + ); + }); +}); + +describe("useBuild", () => { + it("throws when used outside BuildProvider", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => renderHook(() => useBuild())).toThrow( + "useBuild must be used inside BuildProvider", + ); + }); +}); diff --git a/frontend/src/test/CatalogInteraction.test.tsx b/frontend/src/test/CatalogInteraction.test.tsx index 762e66d..437eabd 100644 --- a/frontend/src/test/CatalogInteraction.test.tsx +++ b/frontend/src/test/CatalogInteraction.test.tsx @@ -99,18 +99,22 @@ it("updates search results when the user types", async () => { details: {}, } as PcPart; + // Match useParts: completed requests keep the same result array between renders. + const matchingParts = [rtxPart]; + const emptyParts: PcPart[] = []; // Return results based on the search term vi.mocked(useParts).mockImplementation(({ search } = {}) => { const matchesSearch = search === "RTX"; return { - parts: matchesSearch ? [rtxPart] : [], + parts: matchesSearch ? matchingParts : emptyParts, totalCount: matchesSearch ? 1 : 0, pageInfo: { hasNextPage: false, endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }; }); @@ -167,6 +171,7 @@ it("updates results when the user filters and sorts", async () => { }, ] as PcPart[]; + const emptyParts: PcPart[] = []; // Return results when the expected filters are selected vi.mocked(useParts).mockImplementation( ({ category, sortBy, sortDirection } = {}) => { @@ -174,13 +179,14 @@ it("updates results when the user filters and sorts", async () => { category === "GPU" && sortBy === "PRICE" && sortDirection === "ASC"; return { - parts: matchesFilters ? gpuParts : [], + parts: matchesFilters ? gpuParts : emptyParts, totalCount: matchesFilters ? 2 : 0, pageInfo: { hasNextPage: false, endCursor: null, }, loading: false, + retry: vi.fn(), error: null, }; }, diff --git a/frontend/src/test/DeleteBuildButton.test.tsx b/frontend/src/test/DeleteBuildButton.test.tsx new file mode 100644 index 0000000..ab79dee --- /dev/null +++ b/frontend/src/test/DeleteBuildButton.test.tsx @@ -0,0 +1,74 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import DeleteBuildButton from "@/pages/accountPage/components/DeleteBuildButton"; + +const mocks = vi.hoisted(() => ({ + deleteBuild: vi.fn(), + forgetSavedBuild: vi.fn(), +})); +vi.mock("@/api/builds", () => ({ deleteBuild: mocks.deleteBuild })); +vi.mock("@/context/BuildContext", () => ({ + useSavedBuild: () => ({ forgetSavedBuild: mocks.forgetSavedBuild }), +})); + +beforeEach(() => { + mocks.deleteBuild.mockReset(); + mocks.forgetSavedBuild.mockReset(); + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value() { + this.setAttribute("open", ""); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value() { + this.removeAttribute("open"); + }, + }); +}); +afterEach(cleanup); + +it("opens an accessible confirmation and cancelling does not delete", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Delete My PC" })); + expect( + screen.getByRole("dialog", { name: "Delete build?" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Keep build" })).toHaveFocus(); + fireEvent.click(screen.getByRole("button", { name: "Keep build" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(mocks.deleteBuild).not.toHaveBeenCalled(); +}); + +it("deletes only after confirmation and clears the active saved identity", async () => { + mocks.deleteBuild.mockResolvedValue(undefined); + const onDeleted = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Delete My PC" })); + fireEvent.click(screen.getByRole("button", { name: "Delete build" })); + await waitFor(() => expect(onDeleted).toHaveBeenCalledOnce()); + expect(mocks.deleteBuild).toHaveBeenCalledWith("12"); + expect(mocks.forgetSavedBuild).toHaveBeenCalledWith("12"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); + +it("keeps the dialog and draft intact when deletion fails", async () => { + mocks.deleteBuild.mockRejectedValue(new Error("Could not delete build.")); + const onDeleted = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Delete My PC" })); + fireEvent.click(screen.getByRole("button", { name: "Delete build" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not delete build.", + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(onDeleted).not.toHaveBeenCalled(); + expect(mocks.forgetSavedBuild).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/test/PcPartCard.test.tsx b/frontend/src/test/PcPartCard.test.tsx new file mode 100644 index 0000000..c8e778d --- /dev/null +++ b/frontend/src/test/PcPartCard.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import PcPartCard from "@/components/PcPartCard"; +import { getPartSpecifications } from "@/api/parts"; +import { useBuild } from "@/context/BuildContext"; +import type { PcPart } from "@/types/types"; + +vi.mock("@/api/parts", () => ({ getPartSpecifications: vi.fn() })); +vi.mock("@/context/BuildContext", () => ({ useBuild: vi.fn() })); +vi.mock("@/components/AddToBuildFeedback", () => ({ default: () => null })); + +const part = { + id: "gpu-1", + name: "RTX 4070", + category: "GPU", + manufacturer: "NVIDIA", + price_eur: 599, + image_url: "", + details: {}, +} as PcPart; +const details = { memory: 12 } as PcPart["details"]; +const addPart = vi.fn(); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(useBuild).mockReturnValue({ + parts: {}, + addPart, + removePart: vi.fn(), + resetBuild: vi.fn(), + }); +}); + +it("retains specifications for reopening details and adding from the card", async () => { + const user = userEvent.setup(); + const onShowDetails = vi.fn(); + vi.mocked(getPartSpecifications).mockResolvedValue(details); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Show details" })); + expect(onShowDetails).toHaveBeenCalledWith({ ...part, details }); + + // Closing the parent's dialog leaves the same card mounted. A fresh summary + // can also arrive without discarding its specifications or freezing its price. + const updatedPart = { ...part, price_eur: 499 }; + rerender(); + await user.click(screen.getByRole("button", { name: "Show details" })); + expect(onShowDetails).toHaveBeenLastCalledWith({ ...updatedPart, details }); + await user.click(screen.getByRole("button", { name: "Add to build" })); + + expect(addPart).toHaveBeenCalledWith({ ...updatedPart, details }); + expect(getPartSpecifications).toHaveBeenCalledTimes(1); +}); + +it("shows an error and lets the user retry a failed details request", async () => { + const user = userEvent.setup(); + const onShowDetails = vi.fn(); + vi.mocked(getPartSpecifications) + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(details); + render(); + + await user.click(screen.getByRole("button", { name: "Show details" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not load component details", + ); + expect(onShowDetails).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Show details" })); + expect(onShowDetails).toHaveBeenCalledWith({ ...part, details }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); + +it("disables repeat detail clicks and ignores a response after the card unmounts", async () => { + const user = userEvent.setup(); + const onShowDetails = vi.fn(); + let resolve!: (details: PcPart["details"]) => void; + vi.mocked(getPartSpecifications).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const { unmount } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Show details" })); + expect( + screen.getByRole("button", { name: "Loading details..." }), + ).toBeDisabled(); + const signal = vi.mocked(getPartSpecifications).mock.calls[0][1]; + unmount(); + expect(signal?.aborted).toBe(true); + await act(async () => resolve(details)); + expect(onShowDetails).not.toHaveBeenCalled(); +}); + +it("can still add directly without opening details first", async () => { + const user = userEvent.setup(); + vi.mocked(getPartSpecifications).mockResolvedValue(details); + render(); + + await user.click(screen.getByRole("button", { name: "Add to build" })); + await waitFor(() => + expect(addPart).toHaveBeenCalledWith({ ...part, details }), + ); + expect(getPartSpecifications).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/src/test/README.md b/frontend/src/test/README.md index 9071ac0..702e957 100644 --- a/frontend/src/test/README.md +++ b/frontend/src/test/README.md @@ -1,6 +1,6 @@ # Frontend Testing -The frontend uses automated tests to verify that the PC components catalog works as expected. +We have automated tests for the catalog, compatibility checks, build state, saved-build workflows, and deletion feedback. Tests are written using: @@ -20,7 +20,7 @@ cd frontend Install dependencies: ```bash -npm install +npm ci ``` Run all tests: @@ -35,16 +35,22 @@ This command runs the tests using Vitest and displays the results in the termina All tests are located in `frontend/src/test/`. -| Test File | Description | -| ----------------------------- | --------------------------------------------------------------------------------------- | -| `FilterComponent.test.tsx` | Tests user interaction with the search input. | -| `AllPartsPreview.test.tsx` | Tests search results, empty states, filtering, sorting, pagination, and error messages. | -| `useParts.test.tsx` | Tests the data-fetching hook and its handling of API responses and errors. | -| `CatalogIntegration.test.tsx` | Tests how the search and filter components interact with the results list. | +| Test file | Description | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| [FilterComponent.test.tsx](FilterComponent.test.tsx) | Search input updates. | +| [AllPartsPreview.test.tsx](AllPartsPreview.test.tsx) | Loading, search, filters, sorting, errors/retry, pagination, and card reuse. | +| [CatalogInteraction.test.tsx](CatalogInteraction.test.tsx) | Search, filter, and sort interactions through the catalog UI. | +| [useParts.test.tsx](useParts.test.tsx) | API results/errors, retry, catalog caching, and cache expiry. | +| [useCatalogParams.test.tsx](useCatalogParams.test.tsx) | URL state, reset, category defaults, and browser back navigation. | +| [PcPartCard.test.tsx](PcPartCard.test.tsx) | Fetching and reusing specifications, retry, and cancelled requests. | +| [checkCompatability.test.ts](checkCompatability.test.ts) | Compatibility rules and handling missing component data. | +| [BuildProvider.test.tsx](BuildProvider.test.tsx) | Storage validation and adding, removing, and clearing components. | +| [SavedBuildWorkflow.test.tsx](SavedBuildWorkflow.test.tsx) | Reopening saved builds, draft persistence, account separation, and tracking edits. | +| [DeleteBuildButton.test.tsx](DeleteBuildButton.test.tsx) | Confirmation, cancellation, successful deletion, and failure feedback. | ### Test Scenarios -The following five scenarios are covered: +Our catalog tests cover the following scenarios: #### 1. Search with Matching Results @@ -74,18 +80,12 @@ Simulates an API request failure and verifies that an appropriate error message ### Testing Approach -The tests use mocked API responses to simulate different scenarios without requiring a running backend. +We use mocked API responses and React Testing Library to test interactions without requiring a running backend. Catalog tests mock `useParts` or API calls, and pagination tests simulate `IntersectionObserver`. Saved-build tests also mock authentication and use router wrappers. -React Testing Library is used to render components and simulate user interactions, such as typing in the search field and selecting filters. - -The `useParts` hook is mocked in component tests to control the returned data. - -Pagination tests use a mocked `IntersectionObserver` to simulate infinite scrolling. - -The tests focus on frontend behavior and component interactions. They do not test the actual GraphQL backend or database. +We check loading, empty, error, and retry states as well as successful actions. Dialog and notification APIs are mocked where needed, so these tests do not replace manual keyboard, screen-reader, or responsive-layout checks. See our [accessibility and sustainability documentation](../../../docs/accessibility-and-sustainability.md). ### Test Results -The test suite contains **12 tests across 4 test files**. +On **14 November 2026**, we ran **97 frontend tests across 10 files**, and all passed. -All 12 tests passed during the latest test run. +We also have **85 backend Vitest tests** and **10 Node ownership tests**, all passing in the same check. Run them from `backend/server/` with `npm test`. This also builds the backend before running the ownership suite. We use database mocks/fixtures, so these results do not verify the live VM or real Firebase sign-in. diff --git a/frontend/src/test/SavedBuildWorkflow.test.tsx b/frontend/src/test/SavedBuildWorkflow.test.tsx new file mode 100644 index 0000000..dab9ba3 --- /dev/null +++ b/frontend/src/test/SavedBuildWorkflow.test.tsx @@ -0,0 +1,188 @@ +import { + fireEvent, + render, + screen, + waitFor, + cleanup, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserRouter } from "react-router"; +import BuildProvider from "@/context/BuildProvider"; +import { useSavedBuild } from "@/context/BuildContext"; +import type { PcPart } from "@/types/types"; +import OpenSavedBuild from "@/pages/pcBuilderPage/components/OpenSavedBuild"; + +const mocks = vi.hoisted(() => ({ + auth: { user: { uid: "owner-a" }, loading: false }, + getMyBuild: vi.fn(), +})); +vi.mock("@/context/AuthContext", async () => { + const { createContext } = await import("react"); + return { AuthContext: createContext(mocks.auth) }; +}); +vi.mock("@/components/SuccessNotification", () => ({ default: () => null })); +vi.mock("@/api/builds", () => ({ getMyBuild: mocks.getMyBuild })); + +const cpu = { + id: "cpu-1", + category: "CPU", + name: "Saved CPU", + price_eur: 100, + details: {}, +} as PcPart; +const info = { id: "12", title: "My PC", comment: "", isPublished: false }; + +function Probe() { + const build = useSavedBuild(); + return ( + <> + + {JSON.stringify({ + parts: build.parts, + savedBuild: build.savedBuild, + isDirty: build.isDirty, + })} + + + + + + + ); +} +const state = () => JSON.parse(screen.getByTestId("state").textContent!); + +beforeEach(() => { + localStorage.clear(); + window.history.replaceState(null, "", "/project2/builder"); + mocks.auth.user = { uid: "owner-a" }; + mocks.getMyBuild.mockReset(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("saved build workflow", () => { + it("restores the loaded build ID and selections after remounting and tracks edits", async () => { + const view = render( + + + , + ); + fireEvent.click(screen.getByText("Load fixture")); + await waitFor(() => + expect(localStorage.getItem("pc-builder-v2:owner-a")).toContain( + '"id":"12"', + ), + ); + view.unmount(); + render( + + + , + ); + expect(state().savedBuild.id).toBe("12"); + expect(state().parts.CPU.id).toBe("cpu-1"); + expect(state().isDirty).toBe(false); + fireEvent.click(screen.getByText("Remove CPU")); + expect(state().savedBuild.id).toBe("12"); + expect(state().isDirty).toBe(true); + fireEvent.click(screen.getByText("Clear")); + expect(state().savedBuild).toBeNull(); + }); + + it("keeps local selections as a new draft when the active saved build is deleted", () => { + render( + + + , + ); + fireEvent.click(screen.getByText("Load fixture")); + fireEvent.click(screen.getByText("Forget deleted build")); + expect(state().savedBuild).toBeNull(); + expect(state().parts.CPU.id).toBe("cpu-1"); + expect(state().isDirty).toBe(true); + }); + + it("does not carry a saved build into another account", () => { + const view = render( + + + , + ); + fireEvent.click(screen.getByText("Load fixture")); + mocks.auth.user = { uid: "owner-b" }; + view.rerender( + + + , + ); + expect(state().savedBuild).toBeNull(); + expect(state().parts).toEqual({}); + }); + + it("opens saved parts and removes the URL instruction so refresh cannot overwrite edits", async () => { + window.history.replaceState(null, "", "/project2/builder?build=12"); + mocks.getMyBuild.mockResolvedValue({ + id: "12", + tittel: "My PC", + comment: null, + isPublished: false, + parts: [cpu], + }); + render( + + + + + + + , + ); + await waitFor(() => + expect(screen.getByTestId("state")).toBeInTheDocument(), + ); + expect(state().savedBuild.id).toBe("12"); + expect(state().parts.CPU.name).toBe("Saved CPU"); + expect(window.location.search).toBe(""); + }); + + it("opens the selected saved build directly over an existing draft", async () => { + localStorage.setItem( + "pc-builder-v2:owner-a", + JSON.stringify({ + parts: { CPU: { ...cpu, name: "Previous draft" } }, + savedBuild: null, + isDirty: true, + }), + ); + window.history.replaceState(null, "", "/project2/builder?build=12"); + mocks.getMyBuild.mockResolvedValue({ + id: "12", + tittel: "My PC", + comment: null, + isPublished: false, + parts: [cpu], + }); + render( + + + + + + + , + ); + await waitFor(() => + expect(screen.getByTestId("state")).toBeInTheDocument(), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(state().parts.CPU.name).toBe("Saved CPU"); + expect(state().savedBuild.id).toBe("12"); + expect(state().isDirty).toBe(false); + expect(window.location.search).toBe(""); + }); +}); diff --git a/frontend/src/test/checkCompatability.test.ts b/frontend/src/test/checkCompatability.test.ts new file mode 100644 index 0000000..9a60f4c --- /dev/null +++ b/frontend/src/test/checkCompatability.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it } from "vitest"; +import { checkCompatability } from "@/hooks/compatibility/checkCompatability"; +import { supportedChipsets } from "@/hooks/compatibility/data/supportedChipsets"; +import type { PcPart } from "@/types/types"; + +type Details = Record; + +// Tests only provide the specifications that a check reads, so parts are built loosely. +function part( + category: PcPart["category"], + details: Details | undefined, + name = `Test ${category}`, +) { + return { + id: `${category}-id`, + category, + name, + manufacturer: "Test", + image_url: "", + price_eur: 100, + details, + } as unknown as PcPart; +} + +function result(parts: PcPart[], check: string) { + return checkCompatability({ parts }).find((line) => + line.includes(`] ${check}:`), + ); +} + +// A CPU name that the chipset table knows, with one of its listed chipsets. +const [knownCpu, knownChipsets] = Object.entries(supportedChipsets)[0]; + +const cpu = (details: Details = {}) => + part( + "CPU", + { + socket: "AM5", + specifications: { + memory: { types: ["DDR5"], maxSupport: 128 }, + ppt: 100, + tdp: 65, + }, + ...details, + }, + knownCpu, + ); +const motherboard = (details: Details = {}) => + part("Motherboard", { + socket: "AM5", + chipset: knownChipsets[0], + form_factor: "ATX", + memory: { ram_type: "DDR5", max: 128, slots: 4 }, + ...details, + }); +const ram = (details: Details = {}) => + part("RAM", { + ram_type: "DDR5", + capacity: 32, + modules: { quantity: 2 }, + ...details, + }); +const pcCase = (details: Details = {}) => + part("PCCase", { + supported_motherboard_form_factors: ["ATX", "Micro ATX"], + supported_power_supply_form_factors: ["ATX"], + max_video_card_length: 300, + max_psu_length: 180, + max_cpu_cooler_height: 160, + ...details, + }); + +describe("CPU and motherboard", () => { + it("accepts matching sockets and a listed chipset", () => { + const parts = [cpu(), motherboard()]; + + expect(result(parts, "CPU / motherboard socket")).toMatch(/^\[COMPATIBLE\]/); + expect(result(parts, "CPU / motherboard chipset")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects different sockets", () => { + const parts = [cpu(), motherboard({ socket: "LGA1700" })]; + + expect(result(parts, "CPU / motherboard socket")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + expect(result(parts, "CPU / motherboard chipset")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); + + it("accepts an FM2 CPU on an FM2+ board", () => { + const parts = [cpu({ socket: "FM2" }), motherboard({ socket: "FM2+" })]; + + expect(result(parts, "CPU / motherboard socket")).toMatch(/^\[COMPATIBLE\]/); + }); + + it("does not call an unlisted chipset incompatible", () => { + const parts = [cpu(), motherboard({ chipset: "Not A Real Chipset" })]; + + expect(result(parts, "CPU / motherboard chipset")).toMatch(/^\[UNKNOWN\]/); + }); + + it("checks that the CPU and motherboard use the same memory technology", () => { + expect( + result([cpu(), motherboard()], "CPU / motherboard memory technology"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result( + [cpu(), motherboard({ memory: { ram_type: "DDR4" } })], + "CPU / motherboard memory technology", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("RAM", () => { + it("accepts RAM that matches the motherboard", () => { + const parts = [motherboard(), ram()]; + + expect(result(parts, "RAM / motherboard generation")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard capacity")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects DDR4 RAM on a DDR5 board", () => { + expect( + result( + [motherboard(), ram({ ram_type: "DDR4" })], + "RAM / motherboard generation", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects more RAM capacity or modules than the board supports", () => { + const parts = [ + motherboard({ memory: { ram_type: "DDR5", max: 64, slots: 2 } }), + ram({ capacity: 128, modules: { quantity: 4 } }), + ]; + + expect(result(parts, "RAM / motherboard capacity")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); + + it("rejects laptop SO-DIMM RAM on a desktop board", () => { + expect( + result( + [motherboard(), ram({ form_factor: "260-pin SODIMM" })], + "RAM / motherboard module format", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects RAM capacity above what the CPU supports", () => { + expect( + result( + [ + part("CPU", { + socket: "AM5", + specifications: { memory: { types: ["DDR5"], maxSupport: 64 } }, + }), + ram({ capacity: 128 }), + ], + "RAM / CPU capacity", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("case fit", () => { + it("accepts a motherboard form factor the case supports", () => { + expect( + result([motherboard(), pcCase()], "Motherboard / case form factor"), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("treats mATX and Micro ATX as the same form factor", () => { + expect( + result( + [motherboard({ form_factor: "mATX" }), pcCase()], + "Motherboard / case form factor", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("rejects an ATX board in a mini-ITX only case", () => { + expect( + result( + [ + motherboard(), + pcCase({ supported_motherboard_form_factors: ["Mini ITX"] }), + ], + "Motherboard / case form factor", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("accepts a GPU that fits the case and rejects one that is too long", () => { + const fits = [part("GPU", { length: 280, total_slot_width: 2 }), pcCase()]; + const tooLong = [ + part("GPU", { length: 320, total_slot_width: 2 }), + pcCase(), + ]; + + expect(result(fits, "GPU / case length")).toMatch(/^\[COMPATIBLE\]/); + expect(result(tooLong, "GPU / case length")).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects a GPU that needs more slots than the case has", () => { + expect( + result( + [ + part("GPU", { length: 250, total_slot_width: 3.5 }), + pcCase({ expansion_slots: 3 }), + ], + "GPU / case slot space", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects a power supply that is too long for the case", () => { + const psu = (length: number) => + part("PSU", { form_factor: "ATX", length, wattage: 750 }); + + expect(result([psu(160), pcCase()], "PSU / case length")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result([psu(220), pcCase()], "PSU / case length")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); +}); + +describe("CPU cooler", () => { + const cooler = (details: Details = {}) => + part("CPUCooler", { + cpu_sockets: ["AM5", "AM4"], + height: 150, + water_cooled: false, + fanless: false, + ...details, + }); + + it("accepts a cooler that lists the CPU socket", () => { + expect(result([cpu(), cooler()], "CPU / cooler socket mount")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects a cooler that does not list the CPU socket", () => { + expect( + result( + [cpu(), cooler({ cpu_sockets: ["LGA1700"] })], + "CPU / cooler socket mount", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects an air cooler that is taller than the case allows", () => { + expect( + result([cooler(), pcCase()], "CPU cooler / case height"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result( + [cooler({ height: 175 }), pcCase()], + "CPU cooler / case height", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("leaves liquid cooler radiator fit as unknown", () => { + expect( + result( + [cooler({ water_cooled: true, radiator_size: 240 }), pcCase()], + "CPU cooler / case radiator fit", + ), + ).toMatch(/^\[UNKNOWN\]/); + }); +}); + +describe("storage", () => { + const motherboardWithM2 = (slots: Details[]) => + motherboard({ m2_slots: slots }); + const nvmeSlot = { size: "2280", key: "M", interface: "PCIe 4.0 x4" }; + + it("accepts an NVMe drive in a matching M.2 slot", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { + form_factor: "M.2-2280", + interface: "PCIe 4.0 x4", + }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("rejects an M.2 SATA drive when the board only has NVMe slots", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { form_factor: "M.2-2280", interface: "SATA 6.0 Gb/s" }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects an M.2 drive that is longer than any slot supports", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { + form_factor: "M.2-22110", + interface: "PCIe 4.0 x4", + }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("checks that the case has a drive bay for a 2.5 inch drive", () => { + const drive = part("Storage", { + form_factor: "2.5", + interface: "SATA 6.0 Gb/s", + }); + + expect( + result([drive, pcCase({ internal_2_5_bays: 2 })], "Storage / case drive bay"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result([drive, pcCase({ internal_2_5_bays: 0 })], "Storage / case drive bay"), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("power", () => { + const psu = (wattage: number) => + part("PSU", { wattage, form_factor: "ATX", connectors: {} }); + + // Planning target is (CPU 100 + GPU 200 + 100 W) x 1.25 = 500 W. + const withGpu = (wattage: number) => [ + cpu(), + part("GPU", { tdp: 200, length: 250 }), + psu(wattage), + ]; + + it("accepts a power supply above the estimated target", () => { + expect(result(withGpu(650), "PSU estimated wattage")).toMatch( + /^\[COMPATIBLE\].*500 W/, + ); + }); + + it("warns when the power supply is below the estimated target", () => { + expect(result(withGpu(450), "PSU estimated wattage")).toMatch( + /^\[WARNING\].*500 W/, + ); + }); + + it("accepts a power supply with a 24-pin main connector for the board", () => { + expect( + result( + [ + motherboard({ power_connectors: { main_power: "24-pin" } }), + part("PSU", { wattage: 650, connectors: { atx_24_pin: 1 } }), + ], + "Motherboard / PSU main power", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); +}); + +describe("incomplete or invalid data", () => { + it("reports parts without details as unknown", () => { + const results = checkCompatability({ + parts: [part("CPU", undefined, "Mystery CPU")], + }); + + expect(results).toContainEqual( + expect.stringMatching(/^\[UNKNOWN\] CPU specifications:.*Mystery CPU/), + ); + }); + + it("never turns missing, negative or non-numeric measurements into a pass", () => { + const parts = [ + motherboard({ memory: { ram_type: "DDR5", max: "lots", slots: -1 } }), + ram({ capacity: Number.NaN, modules: { quantity: null } }), + part("GPU", { length: "long", total_slot_width: 0 }), + pcCase({ max_video_card_length: -5 }), + ]; + + expect(result(parts, "RAM / motherboard capacity")).toMatch(/^\[UNKNOWN\]/); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[UNKNOWN\]/, + ); + expect(result(parts, "GPU / case length")).toMatch(/^\[UNKNOWN\]/); + }); + + it("does not crash on details that are not objects", () => { + const parts = [ + part("CPU", "broken" as unknown as Details), + part("Motherboard", [] as unknown as Details), + part("RAM", null as unknown as Details), + ]; + + expect(() => checkCompatability({ parts })).not.toThrow(); + expect(checkCompatability({ parts }).join("\n")).not.toContain( + "[COMPATIBLE]", + ); + }); + + it("reports every category as not selected for an empty build", () => { + const results = checkCompatability({ parts: [] }); + + expect(results).toHaveLength(8); + expect(results.every((line) => line.startsWith("[UNKNOWN]"))).toBe(true); + expect(results.join("\n")).toContain("CPU selection"); + expect(results.join("\n")).toContain("CPUCooler selection"); + }); + + it("only reports one unknown result when a category has several parts", () => { + const results = checkCompatability({ + parts: [cpu(), cpu(), motherboard()], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatch(/^\[UNKNOWN\] Build selection:/); + }); +}); diff --git a/frontend/src/test/useCatalogParams.test.tsx b/frontend/src/test/useCatalogParams.test.tsx new file mode 100644 index 0000000..b4d63f6 --- /dev/null +++ b/frontend/src/test/useCatalogParams.test.tsx @@ -0,0 +1,116 @@ +import { it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation, useNavigate } from "react-router"; + +import { useCatalogParams } from "@/hooks/useCatalogParams"; +import type { PartsOptions } from "@/api/parts"; + +function TestPage({ defaultCategory }: { defaultCategory?: "CPU" }) { + const { search, setSearch, filtOptions, setFiltOptions } = + useCatalogParams(defaultCategory); + const location = useLocation(); + const navigate = useNavigate(); + + return ( + <> + + {JSON.stringify({ search, ...filtOptions })} + + {location.search} + + + + + + ); +} + +function renderAt(url: string, defaultCategory?: "CPU") { + render( + + + , + ); +} + +const state = () => JSON.parse(screen.getByTestId("state").textContent!); +const url = () => screen.getByTestId("url").textContent; + +it("restores search, category and sorting from the URL", () => { + renderAt("/?search=ryzen&category=GPU&sortBy=PRICE&sortDirection=DESC"); + + expect(state()).toEqual({ + search: "ryzen", + category: "GPU", + sortBy: "PRICE", + sortDirection: "DESC", + }); +}); + +it("ignores invalid values in the URL", () => { + renderAt("/?category=Toaster&sortBy=COLOR&sortDirection=UP"); + + expect(state()).toEqual({ search: "" }); +}); + +it("writes search and filters to the URL", async () => { + const user = userEvent.setup(); + renderAt("/"); + + await user.click(screen.getByText("search")); + await user.click(screen.getByText("filter")); + + const params = new URLSearchParams(url()!); + expect(params.get("search")).toBe("RTX 4070"); + expect(params.get("category")).toBe("GPU"); + expect(params.get("sortBy")).toBe("PRICE"); + expect(params.get("sortDirection")).toBe("DESC"); +}); + +it("clears the filters from the URL on reset but keeps the search", async () => { + const user = userEvent.setup(); + renderAt("/?search=ryzen&category=GPU&sortBy=PRICE&sortDirection=DESC"); + + await user.click(screen.getByText("reset")); + + expect(url()).toBe("?search=ryzen"); + expect(state()).toEqual({ search: "ryzen" }); +}); + +it("uses the default category and lets the URL override it", async () => { + const user = userEvent.setup(); + renderAt("/", "CPU"); + expect(state().category).toBe("CPU"); + + await user.click(screen.getByText("filter")); + expect(state().category).toBe("GPU"); + + await user.click(screen.getByText("reset")); + expect(state().category).toBe("CPU"); + expect(url()).toBe(""); +}); + +it("restores the previous filters on browser back", async () => { + const user = userEvent.setup(); + renderAt("/"); + + await user.click(screen.getByText("filter")); + expect(state().category).toBe("GPU"); + + await user.click(screen.getByText("back")); + expect(state().category).toBeUndefined(); +}); diff --git a/frontend/src/test/useParts.test.tsx b/frontend/src/test/useParts.test.tsx index af51969..2d7cead 100644 --- a/frontend/src/test/useParts.test.tsx +++ b/frontend/src/test/useParts.test.tsx @@ -1,21 +1,31 @@ -import { it, expect, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, it, expect, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { useParts } from "@/hooks/useParts"; -import { getParts, type PartsPage } from "@/api/parts"; +import { getParts, type PartsOptions, type PartsPage } from "@/api/parts"; vi.mock("@/api/parts", () => ({ getParts: vi.fn(), })); -vi.mocked(getParts).mockResolvedValue({ +const emptyPage: PartsPage = { parts: [], totalCount: 0, pageInfo: { hasNextPage: false, endCursor: null, }, +}; + +let now = Date.now(); +beforeEach(() => { + // Expire previous tests' entries without exposing cache internals to tests. + now += 60_001; + vi.spyOn(Date, "now").mockImplementation(() => now); + vi.mocked(getParts).mockReset().mockResolvedValue(emptyPage); }); +afterEach(() => vi.restoreAllMocks()); + it("calls getParts with the search term", async () => { renderHook(() => useParts({ search: "RTX" })); @@ -117,3 +127,99 @@ it("returns the correct parts from the API", async () => { expect(result.current.totalCount).toBe(2); expect(result.current.error).toBeNull(); }); + +it("reuses a recent catalogue page when returning to the same filters", async () => { + const gpuPage = { ...emptyPage, totalCount: 100 }; + const cpuPage = { ...emptyPage, totalCount: 50 }; + vi.mocked(getParts) + .mockResolvedValueOnce(gpuPage) + .mockResolvedValueOnce(cpuPage); + const { result, rerender } = renderHook( + (options: PartsOptions) => useParts(options), + { initialProps: { category: "GPU" } as PartsOptions }, + ); + await waitFor(() => expect(result.current.totalCount).toBe(100)); + rerender({ category: "CPU" }); + await waitFor(() => expect(result.current.totalCount).toBe(50)); + rerender({ category: "GPU" }); + await waitFor(() => expect(result.current.totalCount).toBe(100)); + expect(getParts).toHaveBeenCalledTimes(2); +}); + +it("shares cached results with a newly mounted catalogue and refetches after expiry", async () => { + vi.mocked(getParts).mockResolvedValueOnce({ ...emptyPage, totalCount: 100 }); + const first = renderHook(() => useParts({ category: "GPU" })); + await waitFor(() => expect(first.result.current.totalCount).toBe(100)); + first.unmount(); + + const second = renderHook(() => useParts({ category: "GPU" })); + await waitFor(() => expect(second.result.current.totalCount).toBe(100)); + expect(getParts).toHaveBeenCalledTimes(1); + second.unmount(); + + now += 60_001; + vi.mocked(getParts).mockResolvedValueOnce({ ...emptyPage, totalCount: 101 }); + const third = renderHook(() => useParts({ category: "GPU" })); + await waitFor(() => expect(third.result.current.totalCount).toBe(101)); + expect(getParts).toHaveBeenCalledTimes(2); +}); + +it("keeps pagination and sorting results separate and caches pagination metadata", async () => { + const firstPage = { + ...emptyPage, + totalCount: 100, + pageInfo: { hasNextPage: true, endCursor: "page-2" }, + }; + vi.mocked(getParts) + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce({ ...emptyPage, totalCount: 100 }) + .mockResolvedValueOnce({ ...emptyPage, totalCount: 100 }); + const { result, rerender } = renderHook( + (options: PartsOptions) => useParts(options), + { initialProps: { category: "GPU", first: 40 } as PartsOptions }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + rerender({ category: "GPU", first: 40, after: "page-2" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.pageInfo?.hasNextPage).toBe(false); + rerender({ category: "GPU", first: 40, sortBy: "PRICE" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + rerender({ category: "GPU", first: 40 }); + await waitFor(() => + expect(result.current.pageInfo).toEqual(firstPage.pageInfo), + ); + expect(getParts).toHaveBeenCalledTimes(3); +}); + +it("does not cache failed requests", async () => { + vi.mocked(getParts).mockRejectedValueOnce(new Error("Network error")); + const first = renderHook(() => useParts({ search: "RTX" })); + await waitFor(() => expect(first.result.current.error).toBe("Network error")); + first.unmount(); + + const second = renderHook(() => useParts({ search: "RTX" })); + await waitFor(() => expect(second.result.current.loading).toBe(false)); + expect(second.result.current.error).toBeNull(); + expect(getParts).toHaveBeenCalledTimes(2); +}); + +it("retries a failed request with the same filters and pagination", async () => { + vi.mocked(getParts).mockRejectedValueOnce(new Error("Network error")); + const options: PartsOptions = { + search: "RTX", + category: "GPU", + sortBy: "PRICE", + sortDirection: "DESC", + first: 40, + after: "page-2", + }; + const { result } = renderHook(() => useParts(options)); + await waitFor(() => expect(result.current.error).toBe("Network error")); + act(() => result.current.retry()); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeNull(); + expect(getParts).toHaveBeenCalledTimes(2); + expect(vi.mocked(getParts).mock.calls[1][0]).toEqual(options); +});