diff --git a/CONSOLIDATION_PLAN.md b/CONSOLIDATION_PLAN.md index 7d27db3..1700b53 100644 --- a/CONSOLIDATION_PLAN.md +++ b/CONSOLIDATION_PLAN.md @@ -1,6 +1,8 @@ # Server Consolidation & Security Hardening Plan -Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 auth server, (b) put every API endpoint behind real authentication, and (c) standardize on ESM across all Node services. Approach is "do it properly the first time" — no half-finished pieces, no deferred cleanup. +Audit-driven plan to (a) reduce 13 PM2 processes to 5 application servers + 2 auxiliary processes (acot-phone-server, lt-wordlist-api) = 7 total, (b) put every API endpoint behind real authentication, and (c) standardize on ESM across all primary application Node services. Approach is "do it properly the first time" — no half-finished pieces, no deferred cleanup. + +> **Note on the original 12→4 target.** The initial spec called for `12 PM2 processes → 3 application servers + 1 auth server` and "ESM across all Node services". During execution `chat-server` proved a poor merge candidate (different DB, different protocol shape) and was kept as its own process — see Deviations #16 and #27. Phase 9 (added post-audit, 2026-05-24) closes the residual gap: chat-server ESM conversion + in-process `authenticate()` + Caddyfile/CORS hardening + vitest scaffold. --- @@ -10,19 +12,22 @@ Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 au |---|---|---| | 1 — Decommission dead services | **Complete** | aircall/gorgias/clarity/legacy-auth-server deleted from repo + PM2 + Caddyfile + ecosystem.cjs | | 2 — Build shared `lib/` | **Complete** | Lives at `inventory-server/shared/` (see Deviations). `/verify` endpoint live on auth-server | -| 3 — Convert auth-server + inventory-server to ESM | **Complete** | All 58 server-side files ESM; both services live under the ESM build for >24h. See Deviations #10–13 | +| 3 — Convert auth-server + inventory-server to ESM | **Complete** | All inventory-server + auth-server server-side files (58) ESM; both services live under the ESM build for >24h. `chat-server` intentionally not in scope — deferred to Phase 9. See Deviations #10–13, #27 | | 4 — Build `dashboard-server` (the merge) | **Complete (live) — 2026-05-24** | Merged service running on :3015 under PM2; Caddy routes for klaviyo/meta/dashboard-analytics/typeform all reverse-proxy to it. Old per-vendor directories (`klaviyo-server`, `meta-server`, `google-server`, `typeform-server`) and their PM2 entries deleted post-cutover — ~1.27 GB reclaimed (largely duplicated `node_modules`). Phase 6.2 gates wired (meta_write, klaviyo_admin). See Deviations #16–19 | | 5 — Convert `acot-server` to ESM | **Complete (live) — 2026-05-24** | All 11 files (server, db/connection, utils/{phoneAuth,timeUtils}, 7 routes) converted to ESM. PM2 reload clean; SPA-driven `/api/acot/events/*` continues 200 across cutover; phone-server `/api/acot/customers/by-phone` returns 200 with correct shared secret. Phase 6 patterns applied during conversion — see Deviation #24 | | 6 — Auth hardening | **Complete** | All in-process items live: rate-limit, JWT precondition, CORS lockdown, request-log, upload allowlist, `requirePermission` on sensitive routes, permissions seed migration. `authenticate()` live on `/api/*` (inventory-server, dashboard-server) and `/api/acot/*` (acot-server, added in Phase 5). 6.10 lt-wordlist token loaded via `--env-file` + rotated 2026-05-24 (Deviation #25). 6.11 (audit logging) deferred — see Out of scope | | **F1 — Frontend fetch wrapper** | **Complete (live) — 2026-05-23** | Wrappers at `inventory/src/utils/api.ts` (`apiFetch`) and `inventory/src/utils/apiClient.ts` (axios instance). 170 `fetch()` sites across 76 files migrated to `apiFetch`; 32 `axios.*` sites across 11 files migrated to `apiClient`. AuthContext `/login`+`/me`, App.tsx `/me`, and `services/apiv2.ts` (external PHP backend) intentionally left as raw `fetch`. Shipped alongside the Phase 3+6 pm2 reload | | 7 — Caddyfile final form | **Complete — applied 2026-05-24** | Final Caddyfile live at `/etc/caddy/Caddyfile` (forward_auth gate + per-vendor reverse_proxy to :3015). The `inventory-server/deploy/` staging folder was removed after apply — recreate from this doc if future changes are needed. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` | | 8 — ecosystem.config.cjs final form | **Complete — applied 2026-05-24** | Live PM2 list matches the spec below (5 apps + acot-phone-server + lt-wordlist-api = 7 processes). Includes Phase 6.4 JWT_SECRET shadow-override fix and 6.10 lt-wordlist token move. `inventory-server/deploy/` removed post-apply | +| **9 — Post-audit residual gaps** | **Complete (live) — 2026-05-24** | Closes findings from second-look audit: chat-server ESM + in-process `authenticate()` + localhost bind, Caddyfile uploads-gate + edge CORS tightening, vitest scaffold + auth-boundary tests, three Mini\*.jsx fetch leaks, and plan-doc goal reconciliation. PM2 reload, Caddy apply, frontend rebuild, and tests all completed. See Phase 9 section | **Live PM2 process count: 7** (5 application apps — auth-server, inventory-server, chat-server, dashboard-server, acot-server — plus acot-phone-server + lt-wordlist-api). Down from 13 pre-refactor. -**All planned phases complete (2026-05-24).** Phase 5 was the last code-level deliverable; acot-server now runs as an ESM service with shared-lib `authenticate()` defense-in-depth. +**Phases 1–8 complete (2026-05-24).** Phase 5 closed the last originally-planned code deliverable; acot-server now runs as an ESM service with shared-lib `authenticate()` defense-in-depth. -**All originally planned phases shipped.** Two real gaps surfaced during Phase 5 verification — both closed 2026-05-24: +**Phase 9 added and applied 2026-05-24** after a second-look audit surfaced six residual findings — see [Phase 9](#phase-9--post-audit-residual-gaps) below. Code, PM2 reload, Caddy apply, frontend rebuild, and test verification are complete. + +Two earlier gaps surfaced during Phase 5 verification, both closed 2026-05-24: - **Phase 6.10 lt-wordlist token rotation** — fixed via Node's native `--env-file` flag in the PM2 entry; token rotated to a fresh 32-byte hex value in `/opt/lt-wordlist-api/.env` (mode 0600). See Deviation #25. - **Phase 6.1 Caddy gate blocking acot-phone-server's customer lookups** — fixed by repointing `ACOT_API_URL` from the public host to `http://localhost:3012/api/acot`. See Deviation #26. @@ -31,8 +36,8 @@ Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 au ## Goals - Every public-facing endpoint requires a valid auth token (Caddy gate + per-server middleware + per-route permission checks for sensitive operations). -- Reduce service count from 12 PM2 processes to 4: `inventory-server`, `acot-server`, `dashboard-server`, `auth-server`. -- Standardize on ESM (`"type": "module"`) across all Node services. +- Reduce service count from 13 PM2 processes to 7 total: 5 application servers (`auth-server`, `inventory-server`, `dashboard-server`, `acot-server`, `chat-server`) + 2 auxiliary (`acot-phone-server`, `lt-wordlist-api`). The original "4 application servers" target was missed because `chat-server` proved a poor merge candidate during execution; see Deviation #16. +- Standardize on ESM (`"type": "module"`) across all primary application Node services. `chat-server` was the last holdout — Phase 9 converts it. - Decommission `aircall-server`, `gorgias-server`, `clarity-server`, and the legacy `auth-server` (port 3003). - Eliminate dependency duplication: one Redis client, one Postgres pool helper, one logger, one auth middleware — shared across services. @@ -84,7 +89,7 @@ Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 au └──────────────────────┘ ``` -PM2 process count: **12 → 4** (plus `acot-phone-server` and `lt-wordlist-api`, which stay as-is — out of scope). +PM2 process count: **13 → 7** (5 application servers + `acot-phone-server` + `lt-wordlist-api`). The original target was 4 application servers; the diagram above omits `chat-server` because it was originally a merge candidate. In practice `chat-server` runs as its own ESM process post-Phase 9 — see Deviation #16/#27. --- @@ -778,6 +783,151 @@ Estimated effort, end-to-end: **~3 weeks of focused work** by one engineer. Phas --- +## Phase 9 — Post-audit residual gaps (NEW — 2026-05-24) + +A second-look audit after Phase 5/8 closed surfaced six findings that the original plan either deferred, missed, or overstated. Phase 9 closes them. Code work landed in the same session as the audit, then the deploy-side steps (`caddy reload`, `pm2 reload chat-server`, frontend rebuild) were applied and smoke-tested. + +The intent is **"do it properly the first time"** — Phase 9 isn't a quality-of-life backlog, it's the final closing-out of the consolidation project. With §9.1–9.5 applied, the consolidation/security/ESM claims in this document match the live system. + +### 9.1 — chat-server: ESM conversion + in-process `authenticate()` + localhost bind + +**Finding:** `chat-server` was the last application service still using `require()` (Status note misstated this in earlier revisions) and had no per-server auth — it relied entirely on the Caddy gate. `localhost:3014/test-db` returns 200 unauthenticated, falling short of the plan's three-layer defense model (Caddy `forward_auth` + per-server `authenticate()` + per-route `requirePermission`). + +**Files touched (code landed this session):** +- `inventory-server/chat/package.json` — add `"type": "module"`. +- `inventory-server/chat/server.js` — `require` → `import`; load shared `.env` first then local; add a *second* `Pool` against `inventory_db` (the existing `CHAT_DB_*` pool stays for `rocketchat_converted`); mount `shared/auth/middleware.js`'s `authenticate()` on the router; mount `shared/logging/request-log.js`, `shared/cors/policy.js`, `shared/errors/handler.js` for parity with dashboard-server; bind to `127.0.0.1` instead of `0.0.0.0` (external access is via Caddy only). +- `inventory-server/chat/routes.js` — `require` → `import`; `module.exports` → `export default`; no changes to handler bodies (they continue to read `global.pool` which `server.js` still sets). + +**Applied / verified:** +1. `pm2 reload chat-server --update-env` completed; PM2 shows `chat-server` online with the new PID. +2. Syntax sweep passes under Node when excluding macOS `._*` sidecar files. +3. `ss -ltnp` shows `chat-server` bound to `127.0.0.1:3014` (not `0.0.0.0`). +4. Smoke: + - `curl -s http://localhost:3014/test-db` → `401` (was: 200 unauthenticated). + - valid short-lived Bearer token against `https://tools.acherryontop.com/chat-api/test-db` → `200`. + - `curl -s https://tools.acherryontop.com/chat-api/test-db` (no token) → `401` at Caddy gate. +5. PM2 logs show logged-in SPA Chat Archive requests returning 200 (`/users`, `/users/:id/rooms`, `/rooms/:id/messages`, `/messages/attachments`). + +**Rollback:** `pm2 reload chat-server` against the previous commit (CJS version is one git checkout away). + +### 9.2 — Caddyfile: uploads gate fix + edge CORS tightening + +**Findings:** +- `/uploads/*.jpg` returns a *public* `404` because the `@static path *.jpg ... *.woff2` matcher beats `@gated path /api/* /chat-api/* /uploads/*` on specificity, hitting the unauthenticated SPA build root. +- `Access-Control-Allow-Origin "*"` is set inside the `security_headers` snippet and imported into `tools.acherryontop.com`, which silently overrides the careful allow-list in `shared/cors/policy.js`. +- CORS preflight (`OPTIONS` with no Authorization) hits `forward_auth` and 401s before the backend's CORS handler ever sees it. + +**Applied from:** `inventory-server/deploy/Caddyfile.proposed` (recreated this session per Deviation #18's convention of staging diffs there before applying). The active `/etc/caddy/Caddyfile` now contains these Phase 9 edits. + +**Three edits:** +1. Tighten `@static`: + ``` + @static { + path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2 + not path /uploads/* + } + ``` + This makes `/uploads/*` an exclusive @gated path, restoring the intended authenticated file_server behavior. +2. Drop the wildcard headers from `security_headers`: + ```diff + - Access-Control-Allow-Origin "*" + - Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE, PATCH" + - Access-Control-Allow-Headers "DNT, X-CustomHeader, ..." + ``` + CORS responses come from the upstreams (`shared/cors/policy.js`) which set ACAO conditionally against the allow-list. Caddy stamping `*` was a holdover from before Phase 6.6. +3. Add a preflight-bypass handler **before** `@gated`: + ``` + @cors_preflight { + method OPTIONS + header Access-Control-Request-Method * + } + handle @cors_preflight { + handle /api/klaviyo/* { reverse_proxy localhost:3015 } + handle /api/meta/* { reverse_proxy localhost:3015 } + handle /api/dashboard-analytics/* { reverse_proxy localhost:3015 } + handle /api/typeform/* { reverse_proxy localhost:3015 } + handle /api/acot/* { reverse_proxy localhost:3012 } + handle /chat-api/* { + uri strip_prefix /chat-api + reverse_proxy localhost:3014 + } + handle /api/* { reverse_proxy localhost:3010 } + } + ``` + +**Applied / verified:** +1. Caddy admin API load applied and `/etc/caddy/Caddyfile` persisted with the Phase 9 edits. +2. Smoke: + - `curl -I https://tools.acherryontop.com/uploads/reusable/.jpg` → `401` without auth (was: public 404). + - same path with a valid short-lived Bearer token → `200 image/jpeg`. + - `curl -X OPTIONS -H "Origin: https://tools.acherryontop.com" -H "Access-Control-Request-Method: GET" https://tools.acherryontop.com/api/products` → `204` with `Access-Control-Allow-Origin: https://tools.acherryontop.com` (specific, **not** `*`). + - same preflight with `Origin: https://evil.example` → `403` with no wildcard ACAO. + - `curl -s https://tools.acherryontop.com/api/products` (no token) → `401`; wildcard ACAO no longer stamped by Caddy. + +**Rollback:** restore `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` and `curl -X POST .../load` against it. + +### 9.3 — Frontend Mini\*.jsx fetch leaks + +**Finding:** Three dashboard mini-widgets used raw `fetch()` against gated `/api/dashboard/*` endpoints, so the SPA would 401 these specific cards after Phase 6 went live. + +**Files touched (landed this session):** +- `inventory/src/components/dashboard/MiniInventorySnapshot.jsx` — 3 calls migrated to `apiFetch`. +- `inventory/src/components/dashboard/MiniBusinessMetrics.jsx` — 2 calls migrated. +- `inventory/src/components/dashboard/MiniSalesChart.jsx` — 1 call migrated. + +**Sweep done:** `grep -rn "fetch(\`\${config.apiUrl}\|fetch(\`/api/\|fetch('/api/\|fetch(\"/api/" inventory/src` now returns no surviving raw-fetch sites against gated paths (other than the two AuthContext exceptions and `services/apiv2.ts` already excluded by Phase F1). + +**Applied / verified:** `cd inventory && npm run build` completed successfully and the live frontend build now references the new asset hash. Raw `fetch()` sweep now shows only the documented AuthContext/App `/me` exceptions, `services/apiv2.ts`, and third-party OpenWeather calls. + +### 9.4 — Vitest scaffold + auth-boundary tests + +**Finding:** Plan promised a `vitest` scaffold and auth-boundary tests during Phase 2; never delivered. Root `package.json` has no `scripts` block; `inventory-server/package.json` has the default `echo "Error: no test specified"`. + +**Files landed this session** (under `inventory-server/`): +- `inventory-server/package.json` — adds `"test": "vitest run"`, `"test:watch": "vitest"`, plus `vitest` in `devDependencies`. +- `inventory-server/shared/auth/verify.test.js` — five cases: valid token, expired token, wrong-signature token, malformed header, missing token. +- `inventory-server/shared/auth/middleware.test.js` — request with no header → 401; bad header → 401; valid token + inactive user → 403; valid token + missing permission → 403; valid token + correct permission → next() called with `req.user` populated; cache TTL behavior (same token within 60s → one DB hit; after 61s → two). + +The scaffold is intentionally narrow: it covers the security-critical surface and nothing else. Broader coverage is still a separate, larger project — see Out of scope. + +**Applied / verified:** `cd /var/www/inventory && npm test` passes: 2 files, 21 auth-boundary tests. + +### 9.5 — Plan-doc narrative reconciliation + +**Finding:** The opening goal ("12 → 3 application servers + 1 auth-server", "ESM across all Node services") didn't match what shipped. Status row 13 overstated ESM coverage ("All 58 server-side files") in a way that hid chat-server from readers. Deliverables section line 845 acknowledged the chat-server gap but bullet 21 still claimed "all phases complete". + +**Edits landed this session:** +- Headline (line 3) rewritten to "13 PM2 processes to 5 application servers + 2 auxiliary processes = 7 total" + the "primary application Node services" qualifier on ESM. +- Phase 3 status row clarified to scope ESM to inventory-server + auth-server, with explicit reference to Deviation #27. +- Goals (line 34) restated with the actual outcome + the reason for the deviation. +- Target-architecture caption corrected from `12 → 4` to `13 → 7`. +- Concrete deliverables block rewritten to flag the four items where Phase 9 closes a gap (uploads gate, chat-server auth, chat-server ESM, edge CORS). +- New status-table row added for Phase 9 itself. + +**Deploy:** none — this is documentation only. + +### 9.6 — Sequence + dependencies + +| Step | Depends on | Risk | Notes | +|---|---|---|---| +| 9.5 plan-doc | — | none | Already done | +| 9.3 frontend rebuild | 9.5 (no functional dep, just shipping order) | low | `npm run build` from inventory/ | +| 9.4 vitest scaffold | — | none | Already done; run `npm test` to verify | +| 9.1 chat-server reload | code conversion landed this session | medium | **Done** — PM2 reload complete; unauthenticated + authenticated smokes pass | +| 9.2 Caddy reload | 9.1 (so the gate covers a service that already self-authenticates) | medium | **Done** — active Caddyfile includes Phase 9 edits; uploads/CORS smokes pass | + +### 9.7 — Done criteria + +Phase 9 completion checks: +- ✅ `curl -s -o /dev/null -w '%{http_code}' http://localhost:3014/test-db` returns `401` (was: 200). +- ✅ `curl -sI https://tools.acherryontop.com/uploads/reusable/.jpg` returns `401` without auth and `200` with valid Bearer. +- ✅ `curl -X OPTIONS ...` to a gated `/api/*` path returns `204` from the backend with a specific `Access-Control-Allow-Origin`, **not** `*`. +- ✅ `cd /var/www/inventory && npm test` passes 100% on `shared/auth/*.test.js` (2 files, 21 tests). +- ✅ `grep -rn "require(" inventory-server/chat/*.js` returns nothing. +- ✅ Frontend build passes after the three Mini\*.jsx migrations. + +--- + ## Testing strategy No formal test suite exists today (per CLAUDE.md). For a refactor this size, that's a gap to close — but writing tests retroactively for 15K LOC of routes is a separate, larger project. For this refactor: @@ -836,20 +986,21 @@ These came up in the audit but aren't part of this refactor: ## Concrete deliverables -State as of 2026-05-24: all planned phases are **shipped**. Note: the "4 application PM2 processes" original target became **5** in execution because `chat-server` stayed standalone rather than being folded in — never a serious merge candidate (different DB, different protocol shape). +State as of 2026-05-24: Phases 1–9 are **shipped**. The "4 application PM2 processes" original target became **5** in execution because `chat-server` stayed standalone rather than being folded in — never a serious merge candidate (different DB, different protocol shape). - ✅ 5 application PM2 processes instead of 12 (auth-server, inventory-server, dashboard-server, acot-server, chat-server) — plus 2 unchanged (acot-phone-server, lt-wordlist-api) = 7 total. -- ✅ All `/api/*`, `/chat-api/*`, and `/uploads/*` requests gated at Caddy (`forward_auth`). -- ✅ Per-upstream `authenticate()` re-verification on inventory-server, dashboard-server, and acot-server. (`chat-server` still relies on the Caddy gate alone — see asterisk below.) +- ✅ All `/api/*`, `/chat-api/*`, and `/uploads/*` requests gated at Caddy (`forward_auth`). Phase 9 §9.2 fixed the `@static` matcher ordering bug that let `/uploads/*.jpg` slip past the gate. +- ✅ Per-upstream `authenticate()` re-verification on inventory-server, dashboard-server, acot-server, and chat-server. - ✅ Sensitive endpoints additionally gated by per-permission checks (`requirePermission`). -- ⚠️ **One ESM standard — done for auth/inventory/dashboard/acot.** `chat-server` is still CJS (the prior version of this document erroneously claimed it had been converted; verified 2026-05-24 — its `server.js` still uses `require()` and its `package.json` has no `"type": "module"`). Out of scope for this refactor; tracked as future work. +- ✅ **One ESM standard across primary application Node services** — auth-server, inventory-server, dashboard-server, acot-server, and chat-server are ESM. - ✅ One shared `lib/` at `inventory-server/shared/` for auth, logging, DB, errors, CORS. - ✅ Login rate-limited (`shared/rate-limit/login.js`). - ✅ `JWT_SECRET` rotated + ecosystem shadow-override removed. - ✅ Old auth-server, Aircall, Gorgias, Clarity directories deleted from the repo. Defunct `dashboard:gorgias`/`dashboard:calls` permission rows also deleted from DB (2026-05-24). -- ✅ Caddyfile slimmed to one auth-gated block. +- ✅ Caddyfile slimmed to one auth-gated block. Phase 9 §9.2 tightens the `@static` matcher + drops the edge CORS wildcard. - ✅ Permission codes inserted into `permissions` table for granular authorization. -- ✅ No half-finished pieces remain. Both gaps surfaced during Phase 5 verification — `lt-wordlist-api` insecure default token (Deviation #25) and Caddy blocking acot-phone-server's `x-acot-api-key` calls (Deviation #26) — were closed 2026-05-24. +- ✅ Both gaps surfaced during Phase 5 verification — `lt-wordlist-api` insecure default token (Deviation #25) and Caddy blocking acot-phone-server's `x-acot-api-key` calls (Deviation #26) — were closed 2026-05-24. +- ✅ **Six second-look findings closed by Phase 9** (frontend fetch leaks, edge CORS wildcard, uploads-gate ordering, chat-server in-process auth + ESM, vitest scaffold, plan-doc/goal mismatch). Code and deploy steps are complete. --- @@ -926,3 +1077,11 @@ These are decisions made during Phase 1/2 implementation that amend the spec abo **Fix applied — option (a):** changed `ACOT_API_URL` in `/var/www/acot-phone/.env` (and `acot-phone-server/.env.example` and the local repo copy) from `https://tools.acherryontop.com/api/acot` to `http://localhost:3012/api/acot`. Both processes live on netcup, so the request never enters Caddy and lands directly on `requirePhoneApiKey` in-process. Restarted via `pm2 restart acot-phone-server --update-env`; smoke-tested with `curl -H "x-acot-api-key: ..." http://localhost:3012/api/acot/customers/by-phone?phone=...` → 200 with the real customer record. (Alternative option (b), kept here for posterity: add a `@phone_auth header x-acot-api-key *` guard in the Caddyfile to bypass `forward_auth` for requests bearing the shared secret. Would have worked too but introduces a header-based bypass in the gate, which is a worse security posture than just not entering Caddy at all.) + +27. **chat-server stayed CJS through Phase 8, converted to ESM in Phase 9 (2026-05-24).** Why it stayed CJS originally: during Phase 4 scoping, chat-server was a notional merge candidate (fold its routes into `dashboard-server`) but rejected on inspection — it speaks a different DB (`rocketchat_converted`), a different protocol shape (file proxying + message search, not vendor-API aggregation), and shares no service code with the dashboard merge surface. Once "no merge" was decided, the secondary case for converting it (consistency, sharing `shared/auth/middleware.js`) wasn't strong enough to make it worth the Phase 4 testing budget. Phase 6 hardening also skipped it because the Caddy gate was assumed sufficient. + + The second-look audit (2026-05-24) flagged two consequences: + - The plan's literal claim of "ESM across all Node services" was false. + - chat-server had only one defense layer (Caddy `forward_auth`) — `curl http://localhost:3014/test-db` returned 200 unauthenticated. + + Phase 9 §9.1 closes both: chat-server is now ESM, mounts `authenticate()` against an in-process `inventory_db` pool (separate from the existing `rocketchat_converted` pool which routes.js uses via `global.pool`), and binds to `127.0.0.1` so external direct-port access is no longer possible even if the host firewall rule were dropped. Conversion was 3 files (`server.js`, `routes.js`, `package.json`) and minimal because routes.js's only top-level imports were `express` and `path` — handler bodies were untouched. diff --git a/inventory-server/chat/package.json b/inventory-server/chat/package.json index 34db9a0..589b324 100644 --- a/inventory-server/chat/package.json +++ b/inventory-server/chat/package.json @@ -2,6 +2,7 @@ "name": "chat-server", "version": "1.0.0", "description": "Chat archive server for Rocket.Chat data", + "type": "module", "main": "server.js", "scripts": { "start": "node server.js", @@ -12,7 +13,10 @@ "cors": "^2.8.5", "pg": "^8.11.0", "dotenv": "^16.0.3", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "jsonwebtoken": "^9.0.2", + "pino": "^9.5.0", + "pino-http": "^10.3.0" }, "devDependencies": { "nodemon": "^2.0.22" diff --git a/inventory-server/chat/routes.js b/inventory-server/chat/routes.js index 9cbba9c..4c89fa2 100644 --- a/inventory-server/chat/routes.js +++ b/inventory-server/chat/routes.js @@ -1,5 +1,12 @@ -const express = require('express'); -const path = require('path'); +import express from 'express'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM polyfill — Phase 9 §9.1. Handlers below use __dirname to resolve the +// db-convert/db/files/{uploads,avatars} static asset paths. +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + const router = express.Router(); // Serve uploaded files with proper mapping from database paths to actual file locations @@ -646,4 +653,4 @@ router.get('/users/:userId/search', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +export default router; diff --git a/inventory-server/chat/server.js b/inventory-server/chat/server.js index 5f0e2a4..d6be5f7 100644 --- a/inventory-server/chat/server.js +++ b/inventory-server/chat/server.js @@ -1,23 +1,62 @@ -require('dotenv').config({ path: '../.env' }); -const express = require('express'); -const cors = require('cors'); -const { Pool } = require('pg'); -const morgan = require('morgan'); -const chatRoutes = require('./routes'); +// chat-server — Phase 9 §9.1 of CONSOLIDATION_PLAN.md. +// +// ESM conversion + in-process authenticate() defense-in-depth. Previously this +// service relied on the Caddy `forward_auth` gate alone — `localhost:3014` +// was reachable unauthenticated. Now: +// 1. Bound to 127.0.0.1 (was 0.0.0.0) so direct-port access is impossible. +// 2. authenticate() runs against an in-process `inventory_db` pool before +// any route handler sees the request. +// +// Two pools intentionally: +// - `inventoryPool`: used by authenticate() for users/permissions lookups +// against the main inventory_db (matches DB_* env vars). +// - `pool` (set as global.pool for routes.js): the existing +// `rocketchat_converted` pool driven by CHAT_DB_* env vars. routes.js +// reads global.pool throughout — no handler-body changes needed. + +import { config as loadEnv } from 'dotenv'; +import express from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import pg from 'pg'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { authenticate } from '../shared/auth/middleware.js'; +import { corsOptions } from '../shared/cors/policy.js'; +import { errorHandler } from '../shared/errors/handler.js'; +import { requestLog } from '../shared/logging/request-log.js'; + +import chatRoutes from './routes.js'; + +const { Pool } = pg; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Env layering matches dashboard-server (Deviation #18): shared .env wins on +// collisions for security-critical vars, local .env supplies CHAT_DB_*. +const sharedEnvPath = '/var/www/inventory/.env'; +const localEnvPath = path.resolve(__dirname, '.env'); +if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath }); +if (fs.existsSync(localEnvPath)) loadEnv({ path: localEnvPath }); + +if (!process.env.JWT_SECRET) { + console.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)'); + process.exit(1); +} + +const app = express(); +const port = Number(process.env.CHAT_PORT) || 3014; -// Log startup configuration console.log('Starting chat server with config:', { host: process.env.CHAT_DB_HOST, user: process.env.CHAT_DB_USER, database: process.env.CHAT_DB_NAME || 'rocketchat_converted', port: process.env.CHAT_DB_PORT, - chat_port: process.env.CHAT_PORT || 3014 + chat_port: port, }); -const app = express(); -const port = process.env.CHAT_PORT || 3014; - -// Database configuration for rocketchat_converted database +// Rocket.Chat archive pool — routes.js reads it via global.pool. const pool = new Pool({ host: process.env.CHAT_DB_HOST, user: process.env.CHAT_DB_USER, @@ -25,59 +64,69 @@ const pool = new Pool({ database: process.env.CHAT_DB_NAME || 'rocketchat_converted', port: process.env.CHAT_DB_PORT, }); - -// Make pool available globally global.pool = pool; -// Middleware +// inventory_db pool — used by authenticate() for user/permission lookups. +const inventoryPool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: Number(process.env.DB_PORT) || 5432, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, +}); + +app.use(requestLog()); app.use(express.json()); app.use(morgan('combined')); -app.use(cors({ - origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://tools.acherryontop.com'], - credentials: true -})); +app.use(cors(corsOptions)); -// Test database connection endpoint -app.get('/test-db', async (req, res) => { +// /health stays unauthenticated for out-of-band probes — mounted BEFORE +// authenticate() so monitoring tools on the host can poll without a JWT. +// Only reachable via localhost:3014 directly (Caddy routes /health to +// inventory-server:3010, not here). +app.get('/health', (req, res) => res.json({ status: 'healthy' })); + +// Phase 9 §9.1 — per-server auth re-verification. Every chat route must pass +// authenticate() in addition to the Caddy forward_auth gate. +app.use(authenticate({ pool: inventoryPool, secret: process.env.JWT_SECRET })); + +app.get('/test-db', async (req, res, next) => { try { const result = await pool.query('SELECT COUNT(*) as user_count FROM users WHERE active = true'); const messageResult = await pool.query('SELECT COUNT(*) as message_count FROM message'); const roomResult = await pool.query('SELECT COUNT(*) as room_count FROM room'); - res.json({ status: 'success', database: 'rocketchat_converted', stats: { - active_users: parseInt(result.rows[0].user_count), - total_messages: parseInt(messageResult.rows[0].message_count), - total_rooms: parseInt(roomResult.rows[0].room_count) - } - }); - } catch (error) { - console.error('Database test error:', error); - res.status(500).json({ - status: 'error', - error: 'Database connection failed', - details: error.message + active_users: parseInt(result.rows[0].user_count, 10), + total_messages: parseInt(messageResult.rows[0].message_count, 10), + total_rooms: parseInt(roomResult.rows[0].room_count, 10), + }, }); + } catch (err) { + next(err); } }); -// Mount all routes from routes.js app.use('/', chatRoutes); -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ status: 'healthy' }); +app.use(errorHandler); + +// Phase 9 §9.1 — bind to 127.0.0.1. Caddy reverse_proxy targets localhost:3014 +// already; this closes the gap where unauthenticated direct-port access from +// any host on the network was possible. +const server = app.listen(port, '127.0.0.1', () => { + console.log(`Chat server running on 127.0.0.1:${port}`); }); -// Error handling middleware -app.use((err, req, res, next) => { - console.error(err.stack); - res.status(500).json({ error: 'Something broke!' }); -}); - -// Start server -app.listen(port, () => { - console.log(`Chat server running on port ${port}`); -}); \ No newline at end of file +const shutdown = async (signal) => { + console.log(`chat-server shutting down (${signal})`); + server.close(); + try { await pool.end(); } catch { /* ignore */ } + try { await inventoryPool.end(); } catch { /* ignore */ } + process.exit(0); +}; +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/inventory-server/chat/verify-migration.js b/inventory-server/chat/verify-migration.cjs similarity index 100% rename from inventory-server/chat/verify-migration.js rename to inventory-server/chat/verify-migration.cjs diff --git a/inventory-server/deploy/Caddyfile.proposed b/inventory-server/deploy/Caddyfile.proposed new file mode 100644 index 0000000..63c3807 --- /dev/null +++ b/inventory-server/deploy/Caddyfile.proposed @@ -0,0 +1,200 @@ +# Caddyfile — Phase 9 §9.2 proposed form. +# +# Three changes vs. /etc/caddy/Caddyfile (2026-05-24): +# 1. @static matcher now explicitly excludes /uploads/* — without this, an +# uploaded *.jpg matched @static before @gated and slipped past the +# forward_auth gate, hitting the SPA build root and returning a public 404. +# 2. The security_headers snippet no longer sets Access-Control-Allow-* — +# the upstreams' shared/cors/policy.js is the single source of truth for +# CORS responses (Phase 6.6). +# 3. New @cors_preflight handler punts OPTIONS preflights past forward_auth +# so the upstream's CORS middleware can answer them (preflights have no +# Authorization header, so they 401'd at the gate previously). +# +# Apply via the staged-cutover convention in Deviation #8: +# scp this file to netcup:/home/matt/Caddyfile.new +# curl --silent -X POST -H "Content-Type: text/caddyfile" \ +# --data-binary @/home/matt/Caddyfile.new http://localhost:2020/load +# # ...smoke-test, then persist: +# sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.YYYY-MM-DD +# sudo cp /home/matt/Caddyfile.new /etc/caddy/Caddyfile +{ + admin :2020 +} +(security_headers) { + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + X-XSS-Protection "1; mode=block" + Strict-Transport-Security "max-age=31536000; includeSubDomains" + Referrer-Policy "strict-origin-when-cross-origin" + # Phase 9 §9.2: CORS headers removed. Upstreams set ACAO conditionally + # via shared/cors/policy.js; Caddy stamping `*` here was overriding it. + } +} +files.acot.site { + reverse_proxy localhost:8060 +} +pbx.acot.site { + @ws path /ws + handle @ws { + reverse_proxy 127.0.0.1:8088 + } + handle { + reverse_proxy 127.0.0.1:8080 { + header_up Host {host} + header_down Location http://127.0.0.1:8080 https://pbx.acot.site + header_down Location http://pbx.acot.site:8080 https://pbx.acot.site + } + } +} +turn.acot.site { + respond 404 +} +freescout.acot.site { + root * /var/www/freescout/public + encode gzip + php_fastcgi unix//run/php/php8.3-fpm.sock + file_server + # Deny access to dotfiles + @dotfiles path */.* + respond @dotfiles 403 +} +phone.acot.site { + reverse_proxy 127.0.0.1:3020 + encode gzip +} +crafty.acot.site { + reverse_proxy localhost:8443 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-Port 443 + header_up Host {host} + transport http { + tls_insecure_skip_verify + } + } +} +cronicle.acot.site { + reverse_proxy localhost:3100 { + header_up X-Forwarded-Proto https + } +} +inventory.acot.site, acot.site { + redir https://tools.acherryontop.com{uri} permanent +} +tools.acherryontop.com { + import security_headers + + # Public: login endpoint + handle /auth-inv/* { + uri strip_prefix /auth-inv + reverse_proxy localhost:3011 + } + + # Phase 9 §9.2 — CORS preflight bypass. + # Browsers send OPTIONS preflights without Authorization, so forward_auth + # 401s them. Route preflights straight to the upstream which runs + # shared/cors/policy.js and answers correctly. Must come before @static + # and @gated so OPTIONS to *.jpg paths under /uploads/* also work if any + # frontend ever XHRs an upload URL. + @cors_preflight { + method OPTIONS + header Access-Control-Request-Method * + } + handle @cors_preflight { + handle /api/klaviyo/* { + reverse_proxy localhost:3015 + } + handle /api/meta/* { + reverse_proxy localhost:3015 + } + handle /api/dashboard-analytics/* { + reverse_proxy localhost:3015 + } + handle /api/typeform/* { + reverse_proxy localhost:3015 + } + handle /api/acot/* { + reverse_proxy localhost:3012 + } + handle /chat-api/* { + uri strip_prefix /chat-api + reverse_proxy localhost:3014 + } + handle /api/* { + reverse_proxy localhost:3010 + } + } + + # Public: static frontend assets (long-cache). + # Phase 9 §9.2: `not path /uploads/*` ensures uploaded images never get + # served from the SPA build root — they must go through @gated below. + @static { + path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2 + not path /uploads/* + } + handle @static { + header Cache-Control "public, max-age=2592000" + root * /var/www/inventory/frontend/build + file_server + } + + # ----- Authenticated zone ----- + # Phase 6.1: forward_auth subrequest to auth-server:/verify. 2xx → proceeds. + # 401/403 → Caddy returns auth-server response to client; backend never sees it. + @gated path /api/* /chat-api/* /uploads/* + handle @gated { + forward_auth localhost:3011 { + uri /verify + copy_headers Authorization + } + # Phase 6.7: /uploads/* now behind the gate (was a public file_server before). + # Phase 9 §9.2 closes the static-matcher bypass that made this ineffective. + handle /uploads/* { + root * /var/www/inventory + file_server + } + # Phase 4: merged dashboard-server (klaviyo + meta + google + typeform). + handle /api/klaviyo/* { + reverse_proxy localhost:3015 + } + handle /api/meta/* { + reverse_proxy localhost:3015 + } + handle /api/dashboard-analytics/* { + reverse_proxy localhost:3015 + } + handle /api/typeform/* { + reverse_proxy localhost:3015 + } + # ACOT + handle /api/acot/* { + reverse_proxy localhost:3012 + } + # Chat (Phase 9 §9.1 — chat-server now has its own authenticate() too) + handle /chat-api/* { + uri strip_prefix /chat-api + reverse_proxy localhost:3014 + } + # Catch-all: inventory-server + handle /api/* { + reverse_proxy localhost:3010 + } + } + + # Out-of-band probes (unauthenticated) + handle /health { + reverse_proxy localhost:3010 + } + + # SPA fallback (public assets) + handle { + root * /var/www/inventory/frontend/build + try_files {path} /index.html + file_server + encode gzip + } + handle_errors { + respond "{err.status_code} {err.status_text}" + } +} diff --git a/inventory-server/package-lock.json b/inventory-server/package-lock.json index 332b43f..c2ce164 100644 --- a/inventory-server/package-lock.json +++ b/inventory-server/package-lock.json @@ -33,7 +33,8 @@ "uuid": "^9.0.1" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "vitest": "^2.1.9" } }, "node_modules/@emnapi/runtime": { @@ -53,6 +54,397 @@ "license": "0BSD", "optional": true }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -420,6 +812,13 @@ "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -783,6 +1182,356 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -795,6 +1544,126 @@ "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -945,6 +1814,16 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -1159,6 +2038,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", @@ -1188,6 +2077,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -1207,6 +2113,16 @@ "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==", "license": "MIT/X11" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1469,6 +2385,16 @@ "ms": "2.0.0" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -1630,6 +2556,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", @@ -1657,6 +2590,45 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1718,6 +2690,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", @@ -1742,6 +2724,16 @@ "integrity": "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -1938,6 +2930,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2621,6 +3627,13 @@ "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", "license": "Apache-2.0" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -2645,6 +3658,16 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2904,6 +3927,25 @@ "license": "MIT", "optional": true }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/needle": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", @@ -3276,6 +4318,23 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", @@ -3365,6 +4424,13 @@ "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" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -3663,6 +4729,35 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "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", @@ -3994,6 +5089,58 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/run-series": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", @@ -4255,6 +5402,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -4362,6 +5516,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -4413,6 +5577,13 @@ "nan": "^2.20.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -4428,6 +5599,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4554,6 +5732,50 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4703,6 +5925,205 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/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/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/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/vizion": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", @@ -4743,6 +6164,23 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/inventory-server/package.json b/inventory-server/package.json index efb66e8..04c1d7f 100644 --- a/inventory-server/package.json +++ b/inventory-server/package.json @@ -13,7 +13,8 @@ "prod:logs": "pm2 logs inventory-server", "prod:status": "pm2 status inventory-server", "setup": "mkdir -p logs uploads", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "", @@ -43,6 +44,7 @@ "uuid": "^9.0.1" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "vitest": "^2.1.9" } } diff --git a/inventory-server/shared/auth/middleware.test.js b/inventory-server/shared/auth/middleware.test.js new file mode 100644 index 0000000..34c64b2 --- /dev/null +++ b/inventory-server/shared/auth/middleware.test.js @@ -0,0 +1,163 @@ +// Phase 9 §9.4 — vitest scaffold + auth-boundary tests. +// +// Covers shared/auth/middleware.js. Mocks the Postgres pool with a thin +// in-memory fake — no real DB required. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { authenticate, requirePermission } from './middleware.js'; + +const SECRET = 'test-secret-please-do-not-use-in-prod'; + +function makeFakePool(users, permissions = {}) { + const calls = { count: 0 }; + return { + calls, + query: vi.fn(async (sql, params) => { + calls.count += 1; + if (sql.includes('FROM users WHERE id')) { + const user = users[params[0]]; + return { rows: user ? [user] : [] }; + } + if (sql.includes('FROM permissions')) { + return { rows: (permissions[params[0]] || []).map((code) => ({ code })) }; + } + return { rows: [] }; + }), + }; +} + +function makeReq(authHeader) { + return { headers: authHeader ? { authorization: authHeader } : {} }; +} + +function makeRes() { + const res = {}; + res.status = vi.fn(() => res); + res.json = vi.fn(() => res); + return res; +} + +describe('authenticate middleware', () => { + let activeUser; + let inactiveUser; + let validToken; + + beforeEach(() => { + activeUser = { id: 1, username: 'alice', email: 'a@x', is_admin: false, is_active: true }; + inactiveUser = { id: 2, username: 'bob', email: 'b@x', is_admin: false, is_active: false }; + validToken = jwt.sign({ userId: 1 }, SECRET, { expiresIn: '1h' }); + }); + + it('returns 401 when no Authorization header is present', async () => { + const pool = makeFakePool({ 1: activeUser }); + const mw = authenticate({ pool, secret: SECRET }); + const res = makeRes(); + const next = vi.fn(); + await mw(makeReq(), res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when Authorization is not Bearer', async () => { + const pool = makeFakePool({ 1: activeUser }); + const mw = authenticate({ pool, secret: SECRET }); + const res = makeRes(); + const next = vi.fn(); + await mw(makeReq('Basic abc'), res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when token is malformed', async () => { + const pool = makeFakePool({ 1: activeUser }); + const mw = authenticate({ pool, secret: SECRET }); + const res = makeRes(); + const next = vi.fn(); + await mw(makeReq('Bearer not-a-jwt'), res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 403 when the user is inactive', async () => { + const inactiveToken = jwt.sign({ userId: 2 }, SECRET, { expiresIn: '1h' }); + const pool = makeFakePool({ 2: inactiveUser }); + const mw = authenticate({ pool, secret: SECRET }); + const res = makeRes(); + const next = vi.fn(); + await mw(makeReq(`Bearer ${inactiveToken}`), res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() and populates req.user for a valid token + active user', async () => { + const pool = makeFakePool({ 1: activeUser }, { 1: ['products:read'] }); + const mw = authenticate({ pool, secret: SECRET }); + const req = makeReq(`Bearer ${validToken}`); + const res = makeRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(req.user).toBeDefined(); + expect(req.user.id).toBe(1); + expect(req.user.permissions).toEqual(['products:read']); + }); + + it('caches the user lookup — same token within TTL → one DB hit', async () => { + const pool = makeFakePool({ 1: activeUser }, { 1: [] }); + const mw = authenticate({ pool, secret: SECRET }); + const next = vi.fn(); + await mw(makeReq(`Bearer ${validToken}`), makeRes(), next); + await mw(makeReq(`Bearer ${validToken}`), makeRes(), next); + // Two queries on first hit (user + permissions), zero on the second + expect(pool.calls.count).toBe(2); + expect(next).toHaveBeenCalledTimes(2); + }); + + it('refetches after TTL expiry', async () => { + vi.useFakeTimers(); + const pool = makeFakePool({ 1: activeUser }, { 1: [] }); + const mw = authenticate({ pool, secret: SECRET }); + const next = vi.fn(); + await mw(makeReq(`Bearer ${validToken}`), makeRes(), next); + expect(pool.calls.count).toBe(2); + vi.advanceTimersByTime(61_000); + await mw(makeReq(`Bearer ${validToken}`), makeRes(), next); + expect(pool.calls.count).toBe(4); + vi.useRealTimers(); + }); +}); + +describe('requirePermission middleware', () => { + it('returns 401 when req.user is missing', () => { + const mw = requirePermission('products:write'); + const res = makeRes(); + const next = vi.fn(); + mw({}, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() for admin users regardless of code', () => { + const mw = requirePermission('products:write'); + const next = vi.fn(); + mw({ user: { is_admin: true, permissions: [] } }, makeRes(), next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('calls next() when user has the required permission', () => { + const mw = requirePermission('products:write'); + const next = vi.fn(); + mw({ user: { is_admin: false, permissions: ['products:write'] } }, makeRes(), next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('returns 403 when user lacks the required permission', () => { + const mw = requirePermission('products:write'); + const res = makeRes(); + const next = vi.fn(); + mw({ user: { is_admin: false, permissions: ['products:read'] } }, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/inventory-server/shared/auth/verify.test.js b/inventory-server/shared/auth/verify.test.js new file mode 100644 index 0000000..d901e09 --- /dev/null +++ b/inventory-server/shared/auth/verify.test.js @@ -0,0 +1,75 @@ +// Phase 9 §9.4 — vitest scaffold + auth-boundary tests. +// +// Covers the security-critical surface in shared/auth/verify.js. Five cases +// per the original Phase 2 testing-scaffold spec. + +import { describe, it, expect, beforeAll } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { extractBearerToken, verifyToken, TokenError } from './verify.js'; + +const SECRET = 'test-secret-please-do-not-use-in-prod'; +const WRONG_SECRET = 'a-different-secret'; + +let validToken; +let expiredToken; +let wrongSigToken; + +beforeAll(() => { + validToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '1h' }); + expiredToken = jwt.sign({ userId: 42, username: 'alice' }, SECRET, { expiresIn: '-1s' }); + wrongSigToken = jwt.sign({ userId: 42, username: 'alice' }, WRONG_SECRET, { expiresIn: '1h' }); +}); + +describe('extractBearerToken', () => { + it('returns token from a well-formed Bearer header', () => { + expect(extractBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi'); + }); + + it('throws TokenError(missing) when no header is provided', () => { + expect(() => extractBearerToken(undefined)).toThrow(TokenError); + try { extractBearerToken(undefined); } catch (err) { expect(err.code).toBe('missing'); } + }); + + it('throws TokenError(malformed) when header is not Bearer-prefixed', () => { + expect(() => extractBearerToken('Basic abc')).toThrow(TokenError); + try { extractBearerToken('Basic abc'); } catch (err) { expect(err.code).toBe('malformed'); } + }); + + it('throws TokenError(malformed) when Bearer header has empty token', () => { + expect(() => extractBearerToken('Bearer ')).toThrow(TokenError); + try { extractBearerToken('Bearer '); } catch (err) { expect(err.code).toBe('malformed'); } + }); + + it('throws TokenError(missing) when header is not a string', () => { + expect(() => extractBearerToken(null)).toThrow(TokenError); + expect(() => extractBearerToken(42)).toThrow(TokenError); + }); +}); + +describe('verifyToken', () => { + it('returns decoded payload for a valid token', () => { + const decoded = verifyToken(validToken, SECRET); + expect(decoded.userId).toBe(42); + expect(decoded.username).toBe('alice'); + }); + + it('throws TokenError(expired) for an expired token', () => { + expect(() => verifyToken(expiredToken, SECRET)).toThrow(TokenError); + try { verifyToken(expiredToken, SECRET); } catch (err) { expect(err.code).toBe('expired'); } + }); + + it('throws TokenError(invalid) for a wrong-signature token', () => { + expect(() => verifyToken(wrongSigToken, SECRET)).toThrow(TokenError); + try { verifyToken(wrongSigToken, SECRET); } catch (err) { expect(err.code).toBe('invalid'); } + }); + + it('throws TokenError(invalid) for malformed JWT', () => { + expect(() => verifyToken('not-a-jwt', SECRET)).toThrow(TokenError); + try { verifyToken('not-a-jwt', SECRET); } catch (err) { expect(err.code).toBe('invalid'); } + }); + + it('throws TokenError(misconfigured) when secret is missing', () => { + expect(() => verifyToken(validToken, undefined)).toThrow(TokenError); + try { verifyToken(validToken, undefined); } catch (err) { expect(err.code).toBe('misconfigured'); } + }); +}); diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index a8cd1eb..8e8ec2d 100644 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -32,13 +32,81 @@ router.get('/brands', async (req, res) => { }); // Get all products with pagination, filtering, and sorting +// Whitelist of allowed sort keys → SQL column expressions. Used to gate +// `?sort=` against direct interpolation into ORDER BY (the previous code +// dropped req.query.sort straight into the query string — SQL injection sink). +// Keys are the camelCase identifiers the frontend ProductMetricColumnKey union +// emits. Anything not in the map falls back to `p.title`. +const SORT_COLUMN_MAP = { + pid: 'p.pid', + title: 'p.title', + sku: 'p.sku', + barcode: 'p.barcode', + brand: 'p.brand', + line: 'p.line', + subline: 'p.subline', + artist: 'p.artist', + vendor: 'p.vendor', + vendorReference: 'p.vendor_reference', + notionsReference: 'p.notions_reference', + harmonizedTariffCode: 'p.harmonized_tariff_code', + countryOfOrigin: 'p.country_of_origin', + location: 'p.location', + moq: 'p.moq', + weight: 'p.weight', + rating: 'p.rating', + reviews: 'p.reviews', + baskets: 'p.baskets', + notifies: 'p.notifies', + preorderCount: 'p.preorder_count', + notionsInvCount: 'p.notions_inv_count', + dateCreated: 'p.created_at', + dateLastSold: 'p.date_last_sold', + stock: 'p.stock_quantity', + stockQuantity: 'p.stock_quantity', + price: 'p.price', + costPrice: 'p.cost_price', + totalSold: 'p.total_sold', + // product_metrics columns (current schema names; camelCase aliases the + // frontend uses are mapped to the canonical SQL column) + dailySalesAvg: 'pm.avg_sales_per_day_30d', + weeklySalesAvg: 'pm.sales_7d', + monthlySalesAvg: 'pm.avg_sales_per_month_30d', + margin: 'pm.margin_30d', + gmroi: 'pm.gmroi_30d', + gmroi30d: 'pm.gmroi_30d', + inventoryValue: 'pm.current_stock_cost', + costOfGoodsSold: 'pm.cogs_30d', + grossProfit: 'pm.profit_30d', + turnoverRate: 'pm.stockturn_30d', + stockturn30d: 'pm.stockturn_30d', + leadTime: 'pm.config_lead_time', + currentLeadTime: 'pm.config_lead_time', + targetLeadTime: 'pm.config_lead_time', + stockCoverage: 'pm.stock_cover_in_days', + daysOfStock: 'pm.stock_cover_in_days', + reorderPoint: 'pm.replenishment_units', + safetyStock: 'pm.config_safety_stock', + abcClass: 'pm.abc_class', + status: 'pm.status', + ageDays: 'pm.age_days', + sales7d: 'pm.sales_7d', + sales30d: 'pm.sales_30d', + sales365d: 'pm.sales_365d', + revenue7d: 'pm.revenue_7d', + revenue30d: 'pm.revenue_30d', + revenue365d: 'pm.revenue_365d', + sellThrough30d: 'pm.sell_through_30d', + salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev', +}; + router.get('/', async (req, res) => { const pool = req.app.locals.pool; try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const offset = (page - 1) * limit; - const sortColumn = req.query.sort || 'title'; + const sortColumn = SORT_COLUMN_MAP[req.query.sort] || 'p.title'; const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC'; const conditions = ['p.visible = true']; @@ -120,30 +188,28 @@ router.get('/', async (req, res) => { paramCounter++; } - // Handle numeric filters with operators + // Handle numeric filters with operators. Mapped to current product_metrics + // column names; frontend keys (camelCase) preserved for compatibility. const numericFields = { stock: 'p.stock_quantity', price: 'p.price', costPrice: 'p.cost_price', - dailySalesAvg: 'pm.daily_sales_avg', - weeklySalesAvg: 'pm.weekly_sales_avg', - monthlySalesAvg: 'pm.monthly_sales_avg', - avgQuantityPerOrder: 'pm.avg_quantity_per_order', - numberOfOrders: 'pm.number_of_orders', - margin: 'pm.avg_margin_percent', - gmroi: 'pm.gmroi', - inventoryValue: 'pm.inventory_value', - costOfGoodsSold: 'pm.cost_of_goods_sold', - grossProfit: 'pm.gross_profit', - turnoverRate: 'pm.turnover_rate', - leadTime: 'pm.current_lead_time', - currentLeadTime: 'pm.current_lead_time', - targetLeadTime: 'pm.target_lead_time', - stockCoverage: 'pm.days_of_inventory', - daysOfStock: 'pm.days_of_inventory', - weeksOfStock: 'pm.weeks_of_inventory', - reorderPoint: 'pm.reorder_point', - safetyStock: 'pm.safety_stock', + dailySalesAvg: 'pm.avg_sales_per_day_30d', + weeklySalesAvg: 'pm.sales_7d', + monthlySalesAvg: 'pm.avg_sales_per_month_30d', + margin: 'pm.margin_30d', + gmroi: 'pm.gmroi_30d', + inventoryValue: 'pm.current_stock_cost', + costOfGoodsSold: 'pm.cogs_30d', + grossProfit: 'pm.profit_30d', + turnoverRate: 'pm.stockturn_30d', + leadTime: 'pm.config_lead_time', + currentLeadTime: 'pm.config_lead_time', + targetLeadTime: 'pm.config_lead_time', + stockCoverage: 'pm.stock_cover_in_days', + daysOfStock: 'pm.stock_cover_in_days', + reorderPoint: 'pm.replenishment_units', + safetyStock: 'pm.config_safety_stock', // Add new numeric fields preorderCount: 'p.preorder_count', notionsInvCount: 'p.notions_inv_count', @@ -267,88 +333,33 @@ router.get('/', async (req, res) => { 'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand' ); - // Main query with all fields + // Main query with all fields. Aliases new product_metrics column names back to + // the legacy names the frontend ProductRow type still uses — same pattern as the + // /:id detail route below. const query = ` - WITH RECURSIVE - category_path AS ( - SELECT - c.cat_id, - c.name, - c.parent_id, - c.name::text as path - FROM categories c - WHERE c.parent_id IS NULL - - UNION ALL - - SELECT - c.cat_id, - c.name, - c.parent_id, - (cp.path || ' > ' || c.name)::text - FROM categories c - JOIN category_path cp ON c.parent_id = cp.cat_id - ), - product_thresholds AS ( - SELECT - p.pid, - COALESCE( - (SELECT overstock_days FROM stock_thresholds st - WHERE st.category_id IN ( - SELECT pc.cat_id - FROM product_categories pc - WHERE pc.pid = p.pid - ) - AND (st.vendor = p.vendor OR st.vendor IS NULL) - ORDER BY st.vendor IS NULL - LIMIT 1), - (SELECT overstock_days FROM stock_thresholds st - WHERE st.category_id IS NULL - AND (st.vendor = p.vendor OR st.vendor IS NULL) - ORDER BY st.vendor IS NULL - LIMIT 1), - 90 - ) as target_days - FROM products p - ), - product_leaf_categories AS ( - SELECT DISTINCT pc.cat_id - FROM product_categories pc - WHERE NOT EXISTS ( - SELECT 1 - FROM categories child - JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id - WHERE child.parent_id = pc.cat_id - AND child_pc.pid = pc.pid - ) - ) - SELECT + SELECT p.*, COALESCE(p.brand, 'Unbranded') as brand, string_agg(DISTINCT (c.cat_id || ':' || c.name), ',') as categories, - pm.daily_sales_avg, - pm.weekly_sales_avg, - pm.monthly_sales_avg, - pm.avg_quantity_per_order, - pm.number_of_orders, - pm.first_sale_date, - pm.last_sale_date, - pm.days_of_inventory, - pm.weeks_of_inventory, - pm.reorder_point, - pm.safety_stock, - pm.avg_margin_percent, - CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue, - CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value, - CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold, - CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit, - pm.gmroi, + pm.avg_sales_per_day_30d AS daily_sales_avg, + pm.sales_7d AS weekly_sales_avg, + pm.avg_sales_per_month_30d AS monthly_sales_avg, + pm.date_first_sold AS first_sale_date, + pm.date_last_sold AS last_sale_date, + pm.stock_cover_in_days AS days_of_inventory, + pm.replenishment_units AS reorder_point, + pm.config_safety_stock AS safety_stock, + pm.margin_30d AS avg_margin_percent, + CAST(pm.lifetime_revenue AS DECIMAL(15,3)) as total_revenue, + CAST(pm.current_stock_cost AS DECIMAL(15,3)) as inventory_value, + CAST(pm.cogs_30d AS DECIMAL(15,3)) as cost_of_goods_sold, + CAST(pm.profit_30d AS DECIMAL(15,3)) as gross_profit, + pm.gmroi_30d AS gmroi, pm.avg_lead_time_days, - pm.last_purchase_date, - pm.last_received_date, + pm.date_last_received AS last_received_date, pm.abc_class, - pm.stock_status, - pm.turnover_rate, + pm.status AS stock_status, + pm.stockturn_30d AS turnover_rate, p.date_last_sold FROM products p LEFT JOIN product_metrics pm ON p.pid = pm.pid @@ -389,12 +400,12 @@ router.get('/trending', async (req, res) => { try { // First check if we have any data const { rows } = await pool.query(` - SELECT COUNT(*) as count, - MAX(total_revenue) as max_revenue, - MAX(daily_sales_avg) as max_daily_sales, + SELECT COUNT(*) as count, + MAX(lifetime_revenue) as max_revenue, + MAX(avg_sales_per_day_30d) as max_daily_sales, COUNT(DISTINCT pid) as products_with_metrics - FROM product_metrics - WHERE total_revenue > 0 OR daily_sales_avg > 0 + FROM product_metrics + WHERE lifetime_revenue > 0 OR avg_sales_per_day_30d > 0 `); console.log('Product metrics stats:', rows[0]); @@ -403,25 +414,24 @@ router.get('/trending', async (req, res) => { return res.json([]); } - // Get trending products + // Get trending products. growth_rate uses sales_growth_30d_vs_prev — a + // pre-computed % delta of the last 30d window vs the prior 30d window. + // (The old formula compared a per-day rate against a 7-day total, which + // mixed units and produced nonsense after the metrics-schema rename.) const { rows: trendingProducts } = await pool.query(` - SELECT + SELECT p.pid, p.sku, p.title, - COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, - COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg, - CASE - WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0 - THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100 - ELSE 0 - END as growth_rate, - COALESCE(pm.total_revenue, 0) as total_revenue + COALESCE(pm.avg_sales_per_day_30d, 0) as daily_sales_avg, + COALESCE(pm.sales_7d, 0) as weekly_sales_avg, + COALESCE(pm.sales_growth_30d_vs_prev, 0) as growth_rate, + COALESCE(pm.lifetime_revenue, 0) as total_revenue FROM products p INNER JOIN product_metrics pm ON p.pid = pm.pid - WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0) + WHERE (pm.lifetime_revenue > 0 OR pm.avg_sales_per_day_30d > 0) AND p.visible = true - ORDER BY growth_rate DESC + ORDER BY growth_rate DESC NULLS LAST LIMIT 50 `); diff --git a/inventory-server/vitest.config.js b/inventory-server/vitest.config.js new file mode 100644 index 0000000..22805d9 --- /dev/null +++ b/inventory-server/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +// Exclude macOS AppleDouble sidecar files (`._*.js`) that get created when +// editing through the NFS mount from macOS. See Deviation #15 in +// CONSOLIDATION_PLAN.md — these aren't real tests, but vitest's default file +// glob picks them up and fails the suite when rollup tries to parse them. +export default defineConfig({ + test: { + include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/._*', + ], + }, +}); diff --git a/inventory/src/components/dashboard/MiniBusinessMetrics.jsx b/inventory/src/components/dashboard/MiniBusinessMetrics.jsx index 885eddb..8e4db94 100644 --- a/inventory/src/components/dashboard/MiniBusinessMetrics.jsx +++ b/inventory/src/components/dashboard/MiniBusinessMetrics.jsx @@ -4,6 +4,7 @@ import { TrendingUp, DollarSign, Percent, Briefcase } from "lucide-react"; import { DashboardMultiStatCardMini } from "@/components/dashboard/shared"; import { acotService } from "@/services/dashboard/acotService"; import config from "@/config"; +import { apiFetch } from "@/utils/api"; const fmtK = (value) => { if (!value && value !== 0) return "$0"; @@ -56,7 +57,7 @@ const MiniBusinessMetrics = () => { const { data: forecastData, isLoading: forecastLoading } = useQuery({ queryKey: ["mini-forecast-30d"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics`); + const response = await apiFetch(`${config.apiUrl}/dashboard/forecast/metrics`); if (!response.ok) throw new Error("Failed to fetch forecast"); return response.json(); }, @@ -67,7 +68,7 @@ const MiniBusinessMetrics = () => { const { data: yearData, isLoading: yearLoading } = useQuery({ queryKey: ["mini-year-estimate"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/year-revenue-estimate`); + const response = await apiFetch(`${config.apiUrl}/dashboard/year-revenue-estimate`); if (!response.ok) throw new Error("Failed to fetch year estimate"); return response.json(); }, diff --git a/inventory/src/components/dashboard/MiniInventorySnapshot.jsx b/inventory/src/components/dashboard/MiniInventorySnapshot.jsx index 2510887..5812e9d 100644 --- a/inventory/src/components/dashboard/MiniInventorySnapshot.jsx +++ b/inventory/src/components/dashboard/MiniInventorySnapshot.jsx @@ -4,6 +4,7 @@ import { Truck, Warehouse, ShoppingBag, AlertTriangle } from "lucide-react"; import { DashboardMultiStatCardMini } from "@/components/dashboard/shared"; import { acotService } from "@/services/dashboard/acotService"; import config from "@/config"; +import { apiFetch } from "@/utils/api"; const fmtCurrency = (value) => { if (!value && value !== 0) return "$0"; @@ -30,7 +31,7 @@ const MiniInventorySnapshot = () => { const { data: stockData, isLoading: stockLoading } = useQuery({ queryKey: ["mini-stock-metrics"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`); + const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`); if (!response.ok) throw new Error("Failed to fetch stock metrics"); return response.json(); }, @@ -41,7 +42,7 @@ const MiniInventorySnapshot = () => { const { data: replenishData, isLoading: replenishLoading } = useQuery({ queryKey: ["mini-replenish-metrics"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`); + const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`); if (!response.ok) throw new Error("Failed to fetch replenishment"); return response.json(); }, @@ -52,7 +53,7 @@ const MiniInventorySnapshot = () => { const { data: overstockData, isLoading: overstockLoading } = useQuery({ queryKey: ["mini-overstock-metrics"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`); + const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`); if (!response.ok) throw new Error("Failed to fetch overstock"); return response.json(); }, diff --git a/inventory/src/components/dashboard/MiniSalesChart.jsx b/inventory/src/components/dashboard/MiniSalesChart.jsx index 04c61f2..7f55db2 100644 --- a/inventory/src/components/dashboard/MiniSalesChart.jsx +++ b/inventory/src/components/dashboard/MiniSalesChart.jsx @@ -15,6 +15,7 @@ import { AlertCircle, PiggyBank, ShoppingCart } from "lucide-react"; import { formatCurrency } from "./SalesChart.jsx"; import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"; import config from "@/config"; +import { apiFetch } from "@/utils/api"; import { DashboardStatCardMini, DashboardStatCardMiniSkeleton, @@ -85,7 +86,7 @@ const MiniSalesChart = ({ className = "" }) => { startDate: thirtyDaysAgo.toISOString(), endDate: now.toISOString(), }); - const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`); + const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`); if (!response.ok) throw new Error("Failed to fetch sales metrics"); return response.json(); }, diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx index 39e19fa..0fd9ce9 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx @@ -20,6 +20,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { AuthedImage } from "@/components/ui/authed-image"; import { useState, useMemo } from "react"; interface ReusableImage { @@ -248,7 +249,7 @@ export const ProductCard = ({ className="group relative aspect-square border rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary" onClick={() => handleAddReusableImage(image.image_url)} > - {image.name} (
- {row.getValue("name") @@ -707,8 +708,8 @@ export function ReusableImageManagement() {
{previewImage && (
- {previewImage.name} diff --git a/inventory/src/components/ui/authed-image.tsx b/inventory/src/components/ui/authed-image.tsx new file mode 100644 index 0000000..efb8cf6 --- /dev/null +++ b/inventory/src/components/ui/authed-image.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState, type ImgHTMLAttributes } from 'react'; +import { apiFetch } from '@/utils/api'; + +// Browsers cannot attach Authorization headers to requests, but +// Caddy's forward_auth gate (Phase 6.7 / 9.2) demands a Bearer on /uploads/*. +// AuthedImage fetches gated URLs through apiFetch (which adds the Bearer), then +// renders the bytes via an object URL. Non-gated URLs (CDN, external) pass +// straight through to a plain . + +const GATED_PATH_PATTERN = /\/uploads\//; + +function needsAuth(src: string | undefined): boolean { + if (!src) return false; + return GATED_PATH_PATTERN.test(src); +} + +// /uploads/* URLs are stored absolute in the DB (https://tools.acherryontop.com/...). +// Strip the origin so the request goes through the vite dev proxy in dev and stays +// same-origin in prod — either way no CORS preflight on the fetch. +function toFetchUrl(src: string): string { + try { + const parsed = new URL(src, window.location.origin); + return parsed.pathname + parsed.search; + } catch { + return src; + } +} + +type AuthedImageProps = ImgHTMLAttributes & { src?: string }; + +export function AuthedImage({ src, alt, ...rest }: AuthedImageProps) { + const [resolvedSrc, setResolvedSrc] = useState( + needsAuth(src) ? undefined : src, + ); + + useEffect(() => { + if (!needsAuth(src)) { + setResolvedSrc(src); + return; + } + + let objectUrl: string | undefined; + let cancelled = false; + + (async () => { + try { + const res = await apiFetch(toFetchUrl(src!)); + if (!res.ok) { + if (!cancelled) setResolvedSrc(undefined); + return; + } + const blob = await res.blob(); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setResolvedSrc(objectUrl); + } catch { + if (!cancelled) setResolvedSrc(undefined); + } + })(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [src]); + + return {alt}; +} diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 081809d..328515e 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file