From 5174c37a31f53c23ac7b5f5397c4b36c4ef21d0b Mon Sep 17 00:00:00 2001 From: bot-claude Date: Tue, 25 Aug 2026 09:55:13 +0200 Subject: [PATCH] Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code) --- .dockerignore | 8 + .env.example | 45 + .gitignore | 31 + .vscode/extensions.json | 3 + .vscode/launch.json | 34 + .vscode/settings.json | 5 + Dockerfile | 45 + REPRISE.md | 150 + biome.json | 41 + docker-compose.yml | 17 + package.json | 24 + pnpm-lock.yaml | 4395 +++++++++++++++++++++++ pnpm-workspace.yaml | 6 + scripts/backup.sh | 52 + server/drizzle.config.ts | 7 + server/drizzle/0000_heavy_saracen.sql | 25 + server/drizzle/meta/0000_snapshot.json | 168 + server/drizzle/meta/_journal.json | 13 + server/package.json | 33 + server/src/app.ts | 81 + server/src/auth/password.ts | 20 + server/src/auth/rate-limit.ts | 36 + server/src/auth/session.ts | 157 + server/src/config.ts | 64 + server/src/db/client.ts | 36 + server/src/db/schema.ts | 45 + server/src/external/adapters/example.ts | 46 + server/src/external/adapters/http.ts | 104 + server/src/external/adapters/index.ts | 30 + server/src/external/cache.ts | 43 + server/src/external/http-client.ts | 76 + server/src/external/types.ts | 32 + server/src/main.ts | 40 + server/src/routes/auth.ts | 98 + server/src/routes/external.ts | 43 + server/src/routes/health.ts | 7 + server/src/setup.ts | 52 + server/src/validation.ts | 24 + server/tests/api.test.ts | 210 ++ server/tests/helpers.ts | 33 + server/tests/password.test.ts | 28 + server/tests/rate-limit.test.ts | 45 + server/tests/session.test.ts | 125 + server/tsconfig.json | 10 + server/vitest.config.ts | 10 + tsconfig.base.json | 19 + web/index.html | 12 + web/package.json | 33 + web/src/App.tsx | 21 + web/src/components/RequireAuth.tsx | 26 + web/src/index.css | 1 + web/src/lib/api.ts | 79 + web/src/lib/queryClient.ts | 17 + web/src/lib/session.ts | 12 + web/src/main.tsx | 22 + web/src/pages/DashboardPage.tsx | 45 + web/src/pages/LoginPage.tsx | 101 + web/tests/setup.ts | 8 + web/tests/smoke.test.tsx | 91 + web/tsconfig.json | 10 + web/vite.config.ts | 23 + 61 files changed, 7117 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 REPRISE.md create mode 100644 biome.json create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100755 scripts/backup.sh create mode 100644 server/drizzle.config.ts create mode 100644 server/drizzle/0000_heavy_saracen.sql create mode 100644 server/drizzle/meta/0000_snapshot.json create mode 100644 server/drizzle/meta/_journal.json create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/auth/password.ts create mode 100644 server/src/auth/rate-limit.ts create mode 100644 server/src/auth/session.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db/client.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/external/adapters/example.ts create mode 100644 server/src/external/adapters/http.ts create mode 100644 server/src/external/adapters/index.ts create mode 100644 server/src/external/cache.ts create mode 100644 server/src/external/http-client.ts create mode 100644 server/src/external/types.ts create mode 100644 server/src/main.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/external.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/setup.ts create mode 100644 server/src/validation.ts create mode 100644 server/tests/api.test.ts create mode 100644 server/tests/helpers.ts create mode 100644 server/tests/password.test.ts create mode 100644 server/tests/rate-limit.test.ts create mode 100644 server/tests/session.test.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts create mode 100644 tsconfig.base.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/components/RequireAuth.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/queryClient.ts create mode 100644 web/src/lib/session.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/DashboardPage.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/tests/setup.ts create mode 100644 web/tests/smoke.test.tsx create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1841d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +node_modules +**/node_modules +web/dist +server/data +**/.env* +**/coverage +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3d16354 --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# Seiche — variables d'environnement (exemples) +# Copier dans .env (jamais committer le .env !) + +# --- Serveur --- +# Port d'écoute du serveur (API + frontend en production) +SEICHE_PORT=8787 +# Hôte d'écoute. 127.0.0.1 = uniquement la machine locale (recommandé). +# Mettre 0.0.0.0 uniquement si vous placez Caddy/proxy derrière (voir README). +SEICHE_HOST=127.0.0.1 +# Dossier des données (SQLite, secrets) +SEICHE_DATA_DIR=./data +# URL du frontend servant les assets en dev (rarement nécessaire) +# SEICHE_PUBLIC_URL=http://localhost:8787 + +# --- Sessions --- +# Durée de vie d'une session (ms). 8h par défaut. +SEICHE_SESSION_TTL_MS=28800000 +# Durée de vie absolue max d'une session (ms). 7 jours par défaut. +SEICHE_SESSION_ABSOLUTE_TTL_MS=604800000 +# Nombre max de tentatives de connexion par minute (par IP) +SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE=5 + +# --- Service REST externe --- +# "example" = adaptateur de démonstration (données statiques, pour tester le pipeline) +# "http" = adaptateur générique pointant vers une vraie API REST (à paramétrer) +SEICHE_EXTERNAL_ADAPTER=example +# Base URL du service externe (adaptateur http) +SEICHE_EXTERNAL_BASE_URL= +# Clé API / token envoyé au service externe (adaptateur http) +SEICHE_EXTERNAL_API_KEY= +# Chemins d'API (adaptateur http) +SEICHE_EXTERNAL_HEALTH_PATH=/health +SEICHE_EXTERNAL_LIST_PATH=/items +# Timeout des appels sortants (ms) +SEICHE_EXTERNAL_TIMEOUT_MS=10000 +# Nombre de tentatives sur erreur transitoire +SEICHE_EXTERNAL_RETRIES=2 +# TTL du cache serveur des données externes (ms) +SEICHE_EXTERNAL_CACHE_TTL_MS=30000 + +# --- Initialisation (uniquement si aucun utilisateur n'existe) --- +# Nom d'utilisateur et mot de passe de l'admin, fournis via `pnpm setup` +# ou directement ici au premier démarrage : +# SEICHE_ADMIN_USERNAME=admin +# SEICHE_ADMIN_PASSWORD=changez-moi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a35681 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Builds +dist/ +build/ +*.tsbuildinfo + +# Data & secrets (NEVER commit) +data/ +*.sqlite +*.sqlite-* +.env +.env.* +!.env.example +initial-admin-password.txt +*.pem +*.key + +# Logs +logs/ +*.log + +# Editor / OS +.DS_Store +.idea/ +.vite/ + +# Drizzle : ON COMMIT meta/ — le migrateur exige meta/_journal.json au démarrage +# (throw « Can't find meta/_journal.json file » sinon). Rien d'ignoré ici. +backups/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1ce6777 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome", "ms-azuretools.vscode-docker"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9eaa059 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Seiche : serveur API (breakpoints)", + "cwd": "${workspaceFolder}/server", + "runtimeExecutable": "node", + "runtimeArgs": ["--import", "tsx", "src/main.ts"], + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Seiche : web (Vite, port 5173)", + "cwd": "${workspaceFolder}/web", + "runtimeExecutable": "node", + "runtimeArgs": ["node_modules/vite/bin/vite.js"], + "console": "integratedTerminal", + "server": "http://localhost:5173" + } + ], + "compounds": [ + { + "name": "Seiche : dev complet (API + web)", + "configurations": [ + "Seiche : serveur API (breakpoints)", + "Seiche : web (Vite, port 5173)" + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2489ec9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "typescript.updateImportsOnFileMove.enabled": "always" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a38c19d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Seiche — image de run (API Fastify + front Vite statique servis ensemble). +# +# Pas de stage de build séparé : le serveur s'exécute directement en TypeScript +# (tsx, pas de script de build — typecheck fait par CI/local), et le front est +# buildé par Vite puis servi par @fastify/static (SEICHE_PUBLIC_DIR). +# +# Base glibc (node:22-slim) volontairement : argon2 consomme des prébuilds glibc. +FROM node:22-slim + +WORKDIR /app +RUN corepack enable + +# Dépendances workspace (serveur + web), lockfile figé. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY server/package.json server/ +COPY web/package.json web/ +RUN pnpm install --frozen-lockfile + +# Front : build de production (web/dist). +COPY web/ web/ +RUN pnpm --filter seiche-web build + +# Serveur : source TypeScript exécutée par tsx. +COPY server/ server/ + +ENV NODE_ENV=production \ + SEICHE_HOST=0.0.0.0 \ + SEICHE_PORT=8787 \ + SEICHE_DATA_DIR=/data \ + SEICHE_PUBLIC_DIR=/app/web/dist + +RUN mkdir -p /data && chown node:node /data + +EXPOSE 8787 +VOLUME /data + +USER node + +# cwd = server/ pour que `node --import tsx` résolve tsx depuis server/node_modules +# (layout pnpm) et que dotenv retrouve .env comme en dev locale. +WORKDIR /app/server +CMD ["node", "--import", "tsx", "src/main.ts"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.SEICHE_PORT || '8787') + '/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" diff --git a/REPRISE.md b/REPRISE.md new file mode 100644 index 0000000..e56dd96 --- /dev/null +++ b/REPRISE.md @@ -0,0 +1,150 @@ +# Projet « Seiche » — doc de reprise + +> À jour : 2026-08-24 (A1–A5 ✅) · Statut : **scaffold backend installé, compilé, linté, boot + setup admin + smoke login OK** — front absent +> Mode d'emploi : doc auto-suffisant. En reprise avec un assistant IA : lui faire lire ce fichier en entier, traiter le §5, puis **planifier les tâches restantes une par une** (§9 + §11). +> Mode de travail imposé par Nicolas : **petites tâches, planifier avant d'exécuter, ne jamais lancer de run autonome**. + +## 1. Projet en une phrase +Appli web auto-hébergée, **réseau local uniquement, mono-utilisateur** (Nicolas), qui gère un service externe via son API REST. Accès protégé par mot de passe. Le périmètre va grossir et devenir sensible : rigueur et sécurité **dès le départ**, pas en rattrapage. + +## 2. Exigences (telles que déclarées) +- Gestion d'un service externe via API REST (service + périmètre MVP **à préciser**, §5) +- Auto-hébergée, accessible uniquement en réseau local, par le propriétaire +- Protection par mot de passe obligatoire (LAN ≠ confiance totale) +- Deviendra « compliqué et sensible » → fondations sérieuses dès le starter +- Stack imposée : Docker, SQLite, backend Node, front React (ou équivalent validé), tests + +## 3. Stack recommandée (défauts à valider) +| Couche | Choix | Pourquoi | +|---|---|---| +| Langage | TypeScript partout (strict) | Une seule langue, contrats API↔front typés | +| Backend | Node 22 LTS + Fastify 5 | Rapide, validation Zod, erreurs propres, plugins | +| DB | SQLite (better-sqlite3) + Drizzle ORM | Migrations versionnées, typage, mode WAL | +| Front | Vite + React 18 + React Router + TanStack Query | Fetch/caching de l'API REST sans douleur | +| UI | Tailwind CSS + shadcn/ui | Dashboard admin pro, composants en repo | +| Auth | Argon2id + session cookie httpOnly + CSRF | Solide et simple, pas de JWT d'État | +| Tests | Vitest (API via inject, DB de test) + React Testing Library | Tooling TS unique, rapide | +| Qualité | Biome (lint + format) | Un seul outil, très rapide | +| Conteneur | Dockerfile multi-stage + compose, user non-root | Image fine, SQLite en volume | + +## 4. Sécurité — non négociables +1. Hash **Argon2id** (sel par compte), jamais en clair, jamais de hash faible. +2. **Rate-limit** sur /login (anti brute-force). +3. Session : cookie **httpOnly + SameSite=Lax** (+ Secure si TLS), durée limitée, révocation au logout. +4. **CSRF** : token sur toutes les mutations (POST/PUT/DELETE). +5. Secrets **uniquement en variables d'env** (`.env` hors repo, `.env.example` versionné). +6. Logs **pino** structurés : jamais de mot de passe / token / cookie dans les logs. +7. SQLite **hors de l'image** : volume nommé `/data/app.db` + **script de backup** testé. +8. LAN sans TLS = mots de passe en clair sur le réseau → à trancher (§5 q4) : Caddy self-signed, Tailscale, ou HTTP assumé. +9. Dépendances : lockfile figé en prod, `npm audit` au rythme des maj. +10. Surface API minimale, pas de debug en prod. + +## 5. Questions ouvertes (à répondre avant le MVP) +| # | Question | Défaut si pas de réponse | +|---|---|---| +| 1 | **Quel service externe** ? URL, doc API, mode d'auth (token statique / OAuth / Basic ?) | Client isolé `server/src/external/` (adaptateurs), config env, mock en dev | +| 2 | **Périmètre concret du MVP** ? (lister X, modifier Y, déclencher Z, logs…) | 1 CRUD exemple + health | +| 3 | Un seul compte, ou plusieurs possibles ? | Schéma multi-comptes, 1 compte créé au 1er boot | +| 4 | **TLS en LAN** ? (Caddy / Tailscale / HTTP accepté) | HTTP + risque documenté dans le README | +| 5 | UI Tailwind + shadcn/ui, interface en français ? | Oui / FR | +| 6 | Persistance : volume nommé + backup régulier OK ? | Oui | +| 7 | Node 22 LTS dans les conteneurs ? | Oui | +| 8 | ✅ **Docker opérationnel** (résolu 2026-08-23) : daemon 29.7.2 démarré (Arch Linux), compose 5.5.0 (plugin + standalone). | — | +| 9 | **Gitea auto-hébergé** : URL + nom du repo à confirmer. Accès **SSH** choisi ; Nicolas configure la clé le 2026-08-24. (rien sur `localhost:3000` au 2026-08-23) | `git init` local d'abord, remote Gitea dès que la clé sera opérationnelle | + +## 6. Architecture +- **1 conteneur = API + front prébuildé** servi par Fastify → zéro CORS, 1 port, déploiement simple. +- `server/` (Fastify) : plugins (auth, error-handler, rate-limit) → routes `/api/v1/*` → DB + client externe. +- `web/` (Vite+React) : pages, guard de session, TanStack Query devant `/api/v1`. +- Service externe : **un seul package** `server/src/external/` (http-client, cache, types, adaptateurs) — le seul endroit qui le connaît. +- SQLite en **WAL**, migrations Drizzle appliquées au boot. + +## 7. Structure du repo (état réel, inventaire 2026-08-23) +✅ = présent · ❌ = manquant +``` +seiche/ +├── REPRISE.md ✅ · README.md ❌ +├── package.json ✅ · pnpm-workspace.yaml ✅ · biome.json ✅ · tsconfig.base.json ✅ +├── .env.example ✅ · .gitignore ✅ · .dockerignore ❌ +├── Dockerfile ❌ · docker-compose.yml ❌ · Makefile ❌ +├── server/ ✅ (scaffold ~850 lignes — non installé, non compilé, non testé) +│ ├── src/ +│ │ ├── main.ts · app.ts · config.ts · validation.ts +│ │ ├── db/ (client, schema) · migrations ❌ (à générer) +│ │ ├── auth/ (password, session+CSRF, rate-limit) +│ │ ├── routes/ (auth, external, health) +│ │ ├── external/ (http-client, cache, types, adapters/{http,example,index}) +│ │ └── setup.ts ❌ (referencé par `pnpm setup`, absent) +│ ├── drizzle.config.ts · vitest.config.ts · tsconfig.json +│ └── test/ ❌ (aucun test) +└── web/ ❌ (front entier absent) +``` + +## 8. Starter pack — Definition of Done +- [ ] `pnpm dev` : API + web en dev, login → dashboard +- [ ] `docker compose up` : 1 conteneur, SQLite persisté en volume +- [ ] Auth complète : Argon2id, session httpOnly, CSRF, logout, rate-limit /login +- [ ] Table `users` + migration Drizzle, 1er compte créé au 1er boot +- [ ] `/api/v1/health` public, le reste protégé +- [ ] Tests verts (Vitest) : login ok/échec, CSRF, error-handler, DB round-trip +- [ ] Biome + `tsc --noEmit` clean +- [ ] README : dev, prod, env, backup, sécurité + +## 9. Plan d'exécution (session de reprise) +1. Lire ce doc → répondre au §5 (ou valider les défauts) → loger les réponses dans §10. +2. ~~Scaffolder le monorepo~~ → **partiel (2026-08-23)** : racine ✅, `server/` ✅ (non vérifié), `web/` ❌. +3. Backend : config env → DB (Drizzle, WAL, migration) → auth (Argon2id, session, CSRF, rate-limit) → routes. → **code écrit ; reste : installer, migrer, compiler, tester**. +4. Front : Vite+React, login, guard, dashboard minimal, client API. → **à faire**. +5. Docker : Dockerfile multi-stage + compose + volume. +6. Tests + lint + typecheck → cocher le §8. +7. **Mettre à jour ce doc** (statut + §10) à la fin. + +## 10. Journal des décisions +| Date | Décision | +|---|---| +| 2026-08-23 | Stack proposée : TS / Node22+Fastify / SQLite+Drizzle / Vite+React+Tailwind+shadcn / Vitest / Biome (à valider) | +| 2026-08-23 | 1 conteneur pour API + front (pas de CORS, déploiement simple) | +| 2026-08-23 | Workflow fichiers : contenu ajouté par petits blocs en edit (les gros writes timoutent) | +| 2026-08-23 | Docker 29.7.2 présent, plugin compose absent sur l'hôte → à résoudre avant test prod | +| 2026-08-23 | Scaffold `server/` écrit de manière autonome (21 fichiers, ~850 lignes) — **non installé, non compilé, non testé**. Front `web/` absent. | +| 2026-08-23 | Divergences du scaffold vs doc, à trancher : driver SQLite `@libsql/client` (pas better-sqlite3) ; service externe en package `external/` avec adaptateurs (pas un seul fichier) ; rate-limit custom (pas @fastify/rate-limit). | +| 2026-08-23 | Mode de travail imposé : planifier chaque tâche en mode plan, exécuter en petites tâches validées par Nicolas. Inventaire complet dans §11. | +| 2026-08-23 | Audit outils : node v26.7.0 (hôte — conteneur à fixer en 22 LTS), pnpm 11.3.0 ✅, npm 12, git 2.55 (identité user.name/email **non configurée**), sqlite3 CLI 3.53 ✅, make ✅, curl ✅. Gitea : rien sur `localhost:3000` → URL + accès à fournir par Nicolas. | +| 2026-08-23 | Re-test Docker : daemon démarré (server 29.7.2), `docker ps` OK (0 conteneur), compose 5.5.0 présent en plugin **et** standalone. Prêt pour la tâche Docker du §11. Gitea : accès SSH, clé à configurer par Nicolas le 2026-08-24. | +| 2026-08-24 | **A1–A4 faits** : `pnpm install` ✅, migration 0000 générée + appliquée au boot ✅, `tsc --noEmit` clean ✅, Biome clean ✅. Corrections : `biome.json` migré Biome 2.x (`preset`, `clientKind`, `noConsole`, patterns d'ignore), 12 auto-fixes, import inutile retiré, `pino-pretty` ajouté en devDep (boot cassé sans). **A5** : `setup.ts` créé (idempotent, refuse d'écraser, mdp admin ≥ 12, guard sans creds = erreur propre) — smoke : setup + idempotence + boot + `/api/health` + login ok (cookie + CSRF) + session + mdp faux → 401, tous ✅. | +| 2026-08-24 | **B1–B2 faits** : `tests/helpers.ts` (sqlite :memory:, unique par worker, applyMigrations, seedUser, cookies) + `tests/auth.test.ts` (6 tests : health, login ok/échec, corps invalide, argon2) + `tests/api.test.ts` (9 tests : health, login, 401/403 CSRF double-submit, logout + session morte, rate limit 429, error-handler 500 sans fuite, DB round-trip). Suite : 26/26 ✅, tsc ✅, biome ✅. | +| 2026-08-24 | **C3 fait** : `lib/session.ts` (`useSession()` sur `sessionQueryKey`, cache partagé login→guard→dashboard) ; `components/RequireAuth.tsx` (pending = spinner plein écran, `isError` → `navigate('/login', {replace:true})` via useEffect) ; `DashboardPage.tsx` réel (username, expiry locale `fr-FR`, bouton logout → `api.logout` + `removeQueries` + navigate — le catch du logout couvre la session déjà morte côté serveur) ; `App.tsx` : `/login` public, `/` sous `RequireAuth`, catch-all `*` → `/` ; `main.tsx` : QueryClient avec `staleTime: 30s` et `retry` qui exclut les 4xx (pas de retry sur 401/400). Pitfall TS noté : le narrowing de `session` ne propage pas dans le closure `handleLogout` → bind `const s = session` après le guard. tsc ✅, build ✅, biome ✅. | +| 2026-08-24 | **C2 fait** : client API `web/src/lib/api.ts` — `request()` fetch + `credentials: same-origin` + content-type JSON (merge headers corrigé : le spread `...init` écrasait le content-type des POST), `ApiError{status,code,retryAfterMs}`, `sessionQueryKey` pour le cache TanStack. `LoginPage.tsx` — formulaire contrôlé, `useMutation(api.login)`, validation miroir (user 1–64, pass 1–256), `onSuccess` → `setQueryData(sessionQueryKey)` + navigate `/`, erreurs mappées (invalid_credentials / too_many_attempts + retryAfterMs / 500). Contrat aligné `server/src/routes/auth.ts` (codes `invalid_body|invalid_credentials|too_many_attempts|not_authenticated`). tsc ✅, build ✅, biome ✅. | +| 2026-08-24 | **C1 fait** : `web/` scaffoldé (workspace `seiche-web` déjà câblé en racine) : Vite 7 + React 19 + TS strict + Tailwind 4 (plugin vite) + react-router 7 + TanStack Query 5, proxy dev `/api`→8787, `index.html` + entry + App (routes `/` et `/login` placeholders) + `tests/setup.ts` (jsdom + jest-dom 7.0.1 — 6.10 est déprécié/broken, peer `@testing-library/dom` 10.x déclaré). Vérif : tsc ✅, `vite build` ✅ (CSS TW émis), `vitest run` passWithNoTests ✅, biome ✅, dev server :5199 → HTTP 200. | + +## 11. État du repo — inventaire détaillé (2026-08-23) + +**Faits vérifiés :** +- `pnpm install` **jamais lancé** : pas de `node_modules`, pas de `pnpm-lock.yaml` → aucun fichier du scaffold n'a été compilé ni exécuté. +- Migrations Drizzle **absentes** (`drizzle-kit generate` non lancé) alors que `db/client.ts` les applique au boot. +- `server/src/setup.ts` **manquant** alors que `server/package.json` le référence (`pnpm setup`). +- Pas de dépôt git. + +**Plan d'exécution détaillé (2026-08-23, validé par Nicolas — petites tâches, doc mise à jour après chacune) :** +- [x] A1. `pnpm install` (workspace racine + server) +- [x] A2. Migrations Drizzle : `drizzle-kit generate` (0000_heavy_saracen) +- [x] A3. `tsc --noEmit` (fixer les erreurs de compilation) +- [x] A4. `biome check` (fixer lint/format) +- [x] A5. `server/src/setup.ts` (compte admin initial) + smoke test : boot, `/api/v1/health`, login +- [x] B1. Tests server : helper DB de test + login ok/échec (`tests/helpers.ts` + `tests/auth.test.ts`, 6 tests) +- [x] B2. Tests server : CSRF + error-handler + DB round-trip (+rate limit) — `tests/api.test.ts`, 9 tests ; suite : 26/26 +- [x] C1. Scaffold `web/` (Vite+React+TS+Tailwind+Router+TanStack Query) — 2026-08-24, 10 fichiers, typecheck/build/biome ✅, dev :200, proxy `/api`→8787 +- [x] C2. Front : client API + CSRF + page login — 2026-08-24 : `lib/api.ts` (fetch + cookies, `ApiError`, `sessionQueryKey`, login/session/logout) + `LoginPage.tsx` (useMutation, validation miroir serveur, mapping erreurs 401/429/500) ; tsc/build/biome ✅ +- [x] C3. Front : guard session + dashboard minimal + wiring API — 2026-08-24 : `useSession()` (hook TanStack), `RequireAuth` (pending → spinner, erreur → redirect `/login`), `DashboardPage` réel (user + expiry + logout), QueryClient global (staleTime 30 s, pas de retry 4xx) ; tsc/build/biome ✅ +- [x] C4. Tests front (smoke : render login, redirect) — 2026-08-24 : `web/tests/smoke.test.tsx` (vitest+jsdom+RTL, fetch stubbé) : rendu formulaire login, redirect non-auth → /login, login → dashboard ; `cleanup()` explicite dans `tests/setup.ts` (auto-cleanup inactif sans `globals`) ; QueryClient factorisé `web/src/lib/queryClient.ts` (partagé main.tsx ↔ tests) ; tsc/build/biome ✅, 3/3 tests +- [x] D1. Dockerfile multi-stage (Node 22, user non-root) — 2026-08-24 : `Dockerfile` racine (PAS multi-stage au final : le serveur n'a pas d'étape de build, TS exécuté via tsx ; base `node:22-slim` glibc pour les prébuilds `argon2`) : pnpm workspace frozen-lockfile, front `vite build` servi par @fastify/static (`SEICHE_PUBLIC_DIR=/app/web/dist`), `WORKDIR /app/server` (sinon `node --import tsx` ne résout pas tsx avec le layout pnpm), `USER node`, `SEICHE_HOST=0.0.0.0`, `VOLUME /data`, HEALTHCHECK `/api/health` ; `.dockerignore` ; testé : `docker build` + `run` → health ok, `/` et `/login` en 200 +- [x] D2. docker-compose.yml + .dockerignore + volume /data — 2026-08-24 : `docker-compose.yml` racine (build local, image `seiche:latest`, port 8787, volume nommé `seiche-data:/data`, `restart: unless-stopped`, aucune variable requise au runtime) ; testé : up → health ok, `seiche.db` créé dans le volume (owner node) +- [x] D3. Test prod : build image, up, login via API, persistence volume — 2026-08-24 : image buildée + up compose → `/api/health` ok, front 200 ; persistance : `down` (sans -v) → `up` → db intacte ; `setup` exécuté DANS le conteneur (admin créé dans /data/seiche.db) ; `POST /api/auth/login` → 200 avec session + csrfToken +- [x] D4. Script backup SQLite (testé) — 2026-08-24 : `scripts/backup.sh [dir_sortie]` (défaut `backups/`, gitignored) : `VACUUM INTO` dans le conteneur (snapshot cohérent EN LIGNE via `@libsql/client`, le serveur continue de servir) → `docker compose cp` HORS du volume (un backup dans le même volume n'est pas un backup) → nettoyage du tmp → vérif en-tête SQLite + non-vide ; testé : backup de 40K créé, ré-ouvert dans le conteneur et interrogé (`users` → admin présent), /data propre après exécution ; prérequis : conteneur en marche +- [ ] E1. README.md (dev, prod, env, backup, sécurité) +- [ ] E2. `git init` + commit initial (identité git à fournir par Nicolas) +- [ ] E3. Update §8 (Definition of Done) + statut final de ce doc + +**Divergences du scaffold vs doc (à valider ou corriger) :** +- Driver SQLite : `@libsql/client` (scaffold) vs `better-sqlite3` (§3). +- Service externe : package `server/src/external/` avec adaptateurs (scaffold) vs module unique `clients/external.ts` (§6) — fonctionnellement équivalent, à confirmer. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3bd8077 --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git" + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!**/dist", "!**/node_modules", "!**/data", "!**/drizzle/meta"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { + "noConsole": "off" + }, + "style": { + "noNonNullAssertion": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "json": { + "formatter": { + "enabled": true + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3815b89 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +name: seiche + +services: + seiche: + build: . + image: seiche:latest + ports: + - "8787:8787" + volumes: + - seiche-data:/data + restart: unless-stopped + # Variables d'option (toutes ont des valeurs par défaut dans server/src/config.ts) : + # SEICHE_EXTERNAL_ADAPTER, SEICHE_EXTERNAL_BASE_URL, SEICHE_EXTERNAL_API_KEY, + # SEICHE_PORT, SEICHE_SESSION_TTL_MS, ... + +volumes: + seiche-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d91666 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "seiche", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.3.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev:server": "pnpm --filter seiche-server dev", + "dev:web": "pnpm --filter seiche-web dev", + "build": "pnpm --filter seiche-web build", + "test": "pnpm --filter seiche-server test && pnpm --filter seiche-web test", + "typecheck": "pnpm --filter seiche-server typecheck && pnpm --filter seiche-web typecheck", + "lint": "biome check .", + "lint:fix": "biome check --fix .", + "setup": "pnpm --filter seiche-server exec tsx src/setup.ts" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.10", + "typescript": "^5.9.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..628297f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4395 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.5.10 + version: 2.5.10 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + server: + dependencies: + '@fastify/cookie': + specifier: ^11.1.2 + version: 11.1.2 + '@fastify/sensible': + specifier: ^6.0.5 + version: 6.0.5 + '@fastify/static': + specifier: ^10.1.3 + version: 10.1.3 + '@libsql/client': + specifier: ^0.17.4 + version: 0.17.4 + argon2: + specifier: ^0.45.1 + version: 0.45.1 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@libsql/client@0.17.4) + fastify: + specifier: ^5.12.1 + version: 5.12.1 + devDependencies: + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) + + web: + dependencies: + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.102.1(react@19.2.8) + react: + specifier: ^19.1.0 + version: 19.2.8 + react-dom: + specifier: ^19.1.0 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^7.6.0 + version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.0 + version: 4.3.3(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12))) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.1.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.5.0 + version: 4.7.0(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + tailwindcss: + specifier: ^4.1.0 + version: 4.3.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.0.0 + version: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.10': + resolution: {integrity: sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.10': + resolution: {integrity: sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.10': + resolution: {integrity: sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.10': + resolution: {integrity: sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.10': + resolution: {integrity: sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.10': + resolution: {integrity: sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.10': + resolution: {integrity: sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.10': + resolution: {integrity: sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.10': + resolution: {integrity: sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/accept-negotiator@2.1.0': + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.1': + resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==} + + '@fastify/sensible@6.0.5': + resolution: {integrity: sha512-v4mGejwT6EJA2zd2rS6XwlyUSva2ILsqzG0aGkyrlS7l2jIpUgFACclSPN3kDijX//cf8+GutRLhJVKx3ae2Cw==} + + '@fastify/static@10.1.3': + resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@libsql/client@0.17.4': + resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==} + + '@libsql/core@0.17.4': + resolution: {integrity: sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.102.1': + resolution: {integrity: sha512-bJTsrO2ODWSgg+x1leTsK036GBtmNNjnrF1Vk06J5fg/phYZLQIdXrBe0uf5xTItXYvIW1MtbLhyPw7cPMqUUQ==} + + '@tanstack/react-query@5.102.1': + resolution: {integrity: sha512-DKJeRvxjsGUWhUVnVVXSseTS0SKBMLSkrz8dm7i08r93IQQ2gAfUkX1e801gVZ18KSwHiG5yng4tQm5+qSGS0w==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argon2@0.45.1: + resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==} + engines: {node: '>=16.17.0'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.18: + resolution: {integrity: sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + electron-to-chromium@1.5.412: + resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fast-uri@4.1.2: + resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.12.1: + resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-base64@3.9.3: + resolution: {integrity: sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@biomejs/biome@2.5.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.10 + '@biomejs/cli-darwin-x64': 2.5.10 + '@biomejs/cli-linux-arm64': 2.5.10 + '@biomejs/cli-linux-arm64-musl': 2.5.10 + '@biomejs/cli-linux-x64': 2.5.10 + '@biomejs/cli-linux-x64-musl': 2.5.10 + '@biomejs/cli-win32-arm64': 2.5.10 + '@biomejs/cli-win32-x64': 2.5.10 + + '@biomejs/cli-darwin-arm64@2.5.10': + optional: true + + '@biomejs/cli-darwin-x64@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64@2.5.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-x64@2.5.10': + optional: true + + '@biomejs/cli-win32-arm64@2.5.10': + optional: true + + '@biomejs/cli-win32-x64@2.5.10': + optional: true + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@epic-web/invariant@1.0.0': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fastify/accept-negotiator@2.1.0': {} + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@fastify/send@4.1.1': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/sensible@6.0.5': + dependencies: + '@lukeed/ms': 2.0.2 + dequal: 2.0.3 + fastify-plugin: 6.0.0 + forwarded: 0.2.0 + http-errors: 2.0.1 + type-is: 2.1.0 + vary: 1.1.2 + + '@fastify/static@10.1.3': + dependencies: + '@fastify/accept-negotiator': 2.1.0 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.1 + content-disposition: 2.0.1 + fastify-plugin: 6.0.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@libsql/client@0.17.4': + dependencies: + '@libsql/core': 0.17.4 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.9.3 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.17.4': + dependencies: + js-base64: 3.9.3 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + + '@lukeed/ms@2.0.2': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@neon-rs/load@0.0.4': {} + + '@oxc-project/types@0.146.0': {} + + '@phc/format@1.0.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + + '@tanstack/query-core@5.102.1': {} + + '@tanstack/react-query@5.102.1(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.102.1 + react: 19.2.8 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + + '@vitejs/plugin-react@4.7.0(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + abstract-logging@2.0.1: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + argon2@0.45.1: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.18: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.18 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.412 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + buffer-from@1.1.2: {} + + caniuse-lite@1.0.30001809: {} + + chai@6.2.2: {} + + colorette@2.0.20: {} + + content-disposition@2.0.1: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.0.2: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv@17.4.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.12 + + drizzle-orm@0.45.2(@libsql/client@0.17.4): + optionalDependencies: + '@libsql/client': 0.17.4 + + electron-to-chromium@1.5.412: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-copy@4.0.4: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.5: {} + + fast-uri@4.1.2: {} + + fastify-plugin@6.0.0: {} + + fastify@5.12.1: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + forwarded@0.2.0: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + graceful-fs@4.2.11: {} + + help-me@5.0.0: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + ipaddr.js@2.5.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + joycon@3.1.1: {} + + js-base64@3.9.3: {} + + js-tokens@4.0.0: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + media-typer@1.1.1: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@3.0.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-addon-api@8.9.2: {} + + node-gyp-build@4.8.4: {} + + node-releases@2.0.53: {} + + nwsapi@2.2.24: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.4 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + promise-limit@2.7.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@5.0.3: {} + + symbol-tree@3.2.4: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@8.3.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + vary@1.1.2: {} + + vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + tsx: 4.23.12 + + vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + + vitest@4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@26.2.0)(jsdom@26.1.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..cb76c14 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - server + - web +allowBuilds: + argon2: true + esbuild: true diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..e97dbdb --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Seiche — sauvegarde SQLite (en ligne, cohérente, sans arrêter le serveur). +# +# Usage : +# ./scripts/backup.sh [répertoire_de_sortie] # défaut : ./backups +# +# Prérequis : le conteneur `seiche` tourne (docker compose up -d). +# +# Principe : +# 1. `VACUUM INTO` dans le conteneur (snapshot cohérent garanti par SQLite, +# le serveur continue de servir pendant l'opération), +# 2. copie HORS du volume (un backup dans le même volume n'est pas un backup), +# 3. nettoyage du fichier temporaire dans le volume. +set -euo pipefail + +cd "$(dirname "$0")/.." + +OUT_DIR="${1:-backups}" +mkdir -p "$OUT_DIR" +TS="$(date +%Y%m%d-%H%M%S)" +HOST_FILE="$OUT_DIR/seiche-$TS.db" +TMP_IN_CONTAINER="/data/.seiche-backup-tmp.db" + +# 1. Snapshot cohérente dans le conteneur (libsql = dépendance du serveur, +# résolu depuis le WORKDIR /app/server ; l'exécution est en CJS stdin, +# d'où l'import dynamique). +docker compose exec -T seiche node <<'NODE' +import("@libsql/client").then(async (m) => { + const c = m.createClient({ url: "file:/data/seiche.db" }); + await c.execute("VACUUM INTO '/data/.seiche-backup-tmp.db'"); + await c.close(); +}).catch((e) => { + console.error(e); + process.exit(1); +}); +NODE + +# 2. Copie hors du volume. +docker compose cp "seiche:$TMP_IN_CONTAINER" "$HOST_FILE" + +# 3. Nettoyage dans le volume. +docker compose exec -T seiche rm -f "$TMP_IN_CONTAINER" + +# 4. Vérification minimale : en-tête SQLite + fichier non vide. +MAGIC="$(head -c 15 "$HOST_FILE" 2>/dev/null || true)" +if [ "$MAGIC" != "SQLite format 3" ] || [ ! -s "$HOST_FILE" ]; then + echo "ERREUR : backup invalide ($HOST_FILE), suppression." >&2 + rm -f "$HOST_FILE" + exit 1 +fi + +echo "OK : $HOST_FILE ($(du -h "$HOST_FILE" | cut -f1))" diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..9156b13 --- /dev/null +++ b/server/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/db/schema.ts", + out: "./drizzle", +}); diff --git a/server/drizzle/0000_heavy_saracen.sql b/server/drizzle/0000_heavy_saracen.sql new file mode 100644 index 0000000..11a8786 --- /dev/null +++ b/server/drizzle/0000_heavy_saracen.sql @@ -0,0 +1,25 @@ +CREATE TABLE `external_cache` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `fetched_at` integer NOT NULL, + `expires_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `token_hash` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `absolute_expiry_at` integer NOT NULL, + `last_activity_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..98ba06c --- /dev/null +++ b/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,168 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c52dc70f-9d5e-4c32-9abd-2d6e25ee3124", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "external_cache": { + "name": "external_cache", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "absolute_expiry_at": { + "name": "absolute_expiry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..a0d6f3d --- /dev/null +++ b/server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1787524356275, + "tag": "0000_heavy_saracen", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..55e55f3 --- /dev/null +++ b/server/package.json @@ -0,0 +1,33 @@ +{ + "name": "seiche-server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/main.ts", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "node --import tsx src/main.ts", + "setup": "tsx src/setup.ts", + "db:generate": "drizzle-kit generate", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/sensible": "^6.0.5", + "@fastify/static": "^10.1.3", + "@libsql/client": "^0.17.4", + "argon2": "^0.45.1", + "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.2", + "fastify": "^5.12.1" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "drizzle-kit": "^0.31.10", + "pino-pretty": "^13.1.3", + "tsx": "^4.23.12", + "typescript": "^5.9.3", + "vitest": "^4.1.11" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..db663a8 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,81 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import cookie from "@fastify/cookie"; +import fastifyStatic from "@fastify/static"; +import Fastify, { type FastifyInstance } from "fastify"; +import { RateLimiter } from "./auth/rate-limit"; +import { buildSessionPlugin } from "./auth/session"; +import type { Config } from "./config"; +import type { Database } from "./db/client"; +import { createExternalService } from "./external/adapters"; +import { ExternalCache } from "./external/cache"; +import { buildAuthRoutes } from "./routes/auth"; +import { buildExternalRoutes } from "./routes/external"; +import { buildHealthRoutes } from "./routes/health"; + +export interface AppDeps { + config: Config; + db: Database; +} + +export async function buildApp(deps: AppDeps): Promise { + const { config, db } = deps; + + const app = Fastify({ + logger: { + level: process.env.SEICHE_LOG_LEVEL ?? "info", + transport: + process.env.NODE_ENV === "production" + ? undefined + : { + target: "pino-pretty", + options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" }, + }, + }, + }); + + await app.register(cookie); + buildSessionPlugin(app, { db, config }); + + const loginLimiter = new RateLimiter(config.maxLoginAttemptsPerMinute, 60_000); + const externalService = createExternalService(config); + const externalCache = new ExternalCache(db, config.external.cacheTtlMs); + + buildHealthRoutes(app); + buildAuthRoutes(app, { db, config, loginLimiter }); + buildExternalRoutes(app, { service: externalService, cache: externalCache }); + + // Frontend : en production, servir le build Vite (web/dist) si présent. + const publicDir = config.publicDir ?? path.resolve(process.cwd(), "../web/dist"); + if (existsSync(publicDir)) { + await app.register(fastifyStatic, { + root: publicDir, + setHeaders(res) { + // Le HTML ne doit jamais être mis en cache (les assets, si, via hash) + if (res.getHeader("content-type")?.toString().includes("text/html")) { + res.header("cache-control", "no-cache, no-store, must-revalidate"); + } + }, + }); + // Fallback SPA : toute route non /api* renvoie index.html + app.setNotFoundHandler(async (request, reply) => { + if (request.url.startsWith("/api/")) { + return reply.code(404).send({ error: "not_found" }); + } + return reply.sendFile("index.html"); + }); + } + + // Ne jamais fuiter les détails internes en production + app.setErrorHandler((error, _request, reply) => { + const isDev = process.env.NODE_ENV !== "production"; + const details = error instanceof Error ? error.message : undefined; + app.log.error({ err: error }, "erreur interne"); + reply.code(500).send({ + error: "internal_error", + ...(isDev && details ? { details } : {}), + }); + }); + + return app; +} diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts new file mode 100644 index 0000000..4efb157 --- /dev/null +++ b/server/src/auth/password.ts @@ -0,0 +1,20 @@ +import argon2 from "argon2"; + +export const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 19_456, // 19 Mo — OWASP "modéré" + timeCost: 2, + parallelism: 1, +} as const; + +export function hashPassword(password: string): Promise { + return argon2.hash(password, ARGON2_OPTIONS); +} + +export async function verifyPassword(hash: string, password: string): Promise { + try { + return await argon2.verify(hash, password); + } catch { + return false; + } +} diff --git a/server/src/auth/rate-limit.ts b/server/src/auth/rate-limit.ts new file mode 100644 index 0000000..df29166 --- /dev/null +++ b/server/src/auth/rate-limit.ts @@ -0,0 +1,36 @@ +/** + * Rate limiter minimaliste en mémoire (fenêtre glissante). + * Adapté à un usage mono-utilisateur local ; pas besoin de persistance. + */ +export class RateLimiter { + private readonly hits = new Map(); + + constructor( + private readonly maxHits: number, + private readonly windowMs: number, + ) {} + + /** Retourne true si la requête est autorisée (et l'enregistre). */ + hit(key: string, now: Date = new Date()): boolean { + const windowStart = now.getTime() - this.windowMs; + const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart); + if (timestamps.length >= this.maxHits) { + this.hits.set(key, timestamps); + return false; + } + timestamps.push(now.getTime()); + this.hits.set(key, timestamps); + return true; + } + + reset(key: string): void { + this.hits.delete(key); + } + + /** Nombre de tentatives restantes pour la clé (0 = bloqué). */ + remaining(key: string, now: Date = new Date()): number { + const windowStart = now.getTime() - this.windowMs; + const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart); + return Math.max(0, this.maxHits - timestamps.length); + } +} diff --git a/server/src/auth/session.ts b/server/src/auth/session.ts new file mode 100644 index 0000000..f29efb0 --- /dev/null +++ b/server/src/auth/session.ts @@ -0,0 +1,157 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { desc, eq, inArray } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { Config } from "../config"; +import type { Database } from "../db/client"; +import { sessions, users } from "../db/schema"; + +export const SESSION_COOKIE = "seiche_session"; +export const CSRF_COOKIE = "seiche_csrf"; +export const CSRF_HEADER = "x-csrf-token"; + +export interface SessionContext { + userId: number; + username: string; + sessionExpiresAt: Date; +} + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB); +} + +export class SessionManager { + constructor( + private readonly db: Database, + private readonly config: Config, + ) {} + + /** Crée une session (token 256 bits) + un token CSRF (256 bits). */ + async create(userId: number): Promise<{ token: string; csrfToken: string }> { + const now = new Date(); + const token = randomBytes(32).toString("hex"); + const csrfToken = randomBytes(32).toString("hex"); + await this.db.insert(sessions).values({ + tokenHash: sha256Hex(token), + userId, + expiresAt: new Date(now.getTime() + this.config.sessionTtlMs), + absoluteExpiryAt: new Date(now.getTime() + this.config.sessionAbsoluteTtlMs), + lastActivityAt: now, + }); + await this.enforceSessionLimit(userId); + return { token, csrfToken }; + } + + /** Retourne la session valide pour le token, met à jour le TTL glissant. */ + async getValid(token: string | undefined): Promise { + if (!token) return null; + const now = new Date(); + const tokenHash = sha256Hex(token); + const rows = await this.db + .select({ session: sessions, user: users }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(eq(sessions.tokenHash, tokenHash)) + .limit(1); + const row = rows[0]; + if (!row) return null; + const s = row.session; + if (now >= s.absoluteExpiryAt || now >= s.expiresAt) { + await this.db.delete(sessions).where(eq(sessions.tokenHash, tokenHash)); + return null; + } + const slidingExpiry = new Date(now.getTime() + this.config.sessionTtlMs); + const effectiveExpiry = + slidingExpiry <= s.absoluteExpiryAt ? slidingExpiry : s.absoluteExpiryAt; + await this.db + .update(sessions) + .set({ expiresAt: effectiveExpiry, lastActivityAt: now }) + .where(eq(sessions.tokenHash, tokenHash)); + return { + userId: row.user.id, + username: row.user.username, + sessionExpiresAt: effectiveExpiry, + }; + } + + async destroy(token: string | undefined): Promise { + if (!token) return; + await this.db.delete(sessions).where(eq(sessions.tokenHash, sha256Hex(token))); + } + + /** Garde un maximum de 5 sessions actives par utilisateur. */ + private async enforceSessionLimit(userId: number): Promise { + const all = await this.db + .select({ tokenHash: sessions.tokenHash, createdAt: sessions.createdAt }) + .from(sessions) + .where(eq(sessions.userId, userId)) + .orderBy(desc(sessions.createdAt), desc(sessions.tokenHash)); + if (all.length <= 5) return; + const toRemove = all.slice(5).map((s) => s.tokenHash); + if (toRemove.length > 0) { + await this.db.delete(sessions).where(inArray(sessions.tokenHash, toRemove)); + } + } +} + +declare module "fastify" { + interface FastifyRequest { + session: SessionContext | null; + } + interface FastifyInstance { + sessionManager: SessionManager; + authenticate(request: FastifyRequest, reply: FastifyReply): Promise; + requireCsrf(request: FastifyRequest, reply: FastifyReply): Promise; + } +} + +/** + * Plugin sessions : + * - décore `request.session` (résilée à chaque requête /api/*) + * - expose `app.authenticate` et `app.requireCsrf` (pre-handlers) + * + * À enregistrer APRÈS @fastify/cookie (le parsing des cookies doit précéder). + */ +export function buildSessionPlugin( + app: FastifyInstance, + deps: { db: Database; config: Config }, +): void { + const manager = new SessionManager(deps.db, deps.config); + + app.decorateRequest("session", null); + app.decorate("sessionManager", manager); + + app.addHook("onRequest", async (request: FastifyRequest) => { + if (!request.url.startsWith("/api/")) return; + request.session = await manager.getValid(request.cookies[SESSION_COOKIE]); + }); + + app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply) => { + if (!request.session) { + reply.code(401).send({ error: "not_authenticated" }); + return; + } + }); + + app.decorate("requireCsrf", async (request: FastifyRequest, reply: FastifyReply) => { + if (SAFE_METHODS.has(request.method)) return; + const cookie = request.cookies[CSRF_COOKIE]; + const header = request.headers[CSRF_HEADER]; + if (!cookie || typeof header !== "string" || header.length === 0) { + reply.code(403).send({ error: "csrf_invalid" }); + return; + } + if (!safeEqual(cookie, header)) { + reply.code(403).send({ error: "csrf_invalid" }); + } + }); +} + +export { CSRF_COOKIE as csrfCookieName, SESSION_COOKIE as sessionCookieName }; diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..6608f75 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { config as dotenvConfig } from "dotenv"; + +// Chargement du .env : d'abord le .env du paquet serveur, puis celui de la racine du repo. +// Les variables déjà présentes dans l'environnement ne sont jamais écrasées. +dotenvConfig({ path: [".env", "../.env"] }); + +export interface Config { + port: number; + host: string; + dataDir: string; + sessionTtlMs: number; + sessionAbsoluteTtlMs: number; + maxLoginAttemptsPerMinute: number; + publicDir: string | null; + external: { + adapter: "example" | "http"; + baseUrl: string; + apiKey: string; + healthPath: string; + listPath: string; + timeoutMs: number; + retries: number; + cacheTtlMs: number; + }; +} + +function envStr(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value === "" ? fallback : value; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value <= 0) { + throw new Error(`Variable d'environnement ${name} invalide : ${raw} (entier positif attendu)`); + } + return value; +} + +export function loadConfig(): Config { + const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data")); + return { + port: envInt("SEICHE_PORT", 8787), + host: envStr("SEICHE_HOST", "127.0.0.1"), + dataDir, + sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000), + sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000), + maxLoginAttemptsPerMinute: envInt("SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE", 5), + publicDir: process.env.SEICHE_PUBLIC_DIR ?? null, + external: { + adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http", + baseUrl: envStr("SEICHE_EXTERNAL_BASE_URL", ""), + apiKey: envStr("SEICHE_EXTERNAL_API_KEY", ""), + healthPath: envStr("SEICHE_EXTERNAL_HEALTH_PATH", "/health"), + listPath: envStr("SEICHE_EXTERNAL_LIST_PATH", "/items"), + timeoutMs: envInt("SEICHE_EXTERNAL_TIMEOUT_MS", 10_000), + retries: envInt("SEICHE_EXTERNAL_RETRIES", 2), + cacheTtlMs: envInt("SEICHE_EXTERNAL_CACHE_TTL_MS", 30_000), + }, + }; +} diff --git a/server/src/db/client.ts b/server/src/db/client.ts new file mode 100644 index 0000000..2596ec6 --- /dev/null +++ b/server/src/db/client.ts @@ -0,0 +1,36 @@ +import path from "node:path"; +import { type Client, createClient } from "@libsql/client"; +import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import * as schema from "./schema"; + +export type Database = LibSQLDatabase; + +export function createDbClient(url: string): Client { + return createClient({ url }); +} + +export function createDb(client: Client): Database { + return drizzle(client, { schema }); +} + +/** Applique les migrations SQL (idempotent, journal géré par Drizzle). */ +export async function runMigrations(db: Database, migrationsFolder: string): Promise { + await migrate(db, { migrationsFolder }); +} + +/** Ouvre (ou crée) la base SQLite au format fichier. */ +export async function openDatabase(dataDir: string, migrationsFolder: string): Promise { + const client = createDbClient(path.join("file:", dataDir, "seiche.db")); + const db = createDb(client); + await runMigrations(db, migrationsFolder); + return db; +} + +/** Ouvre une base SQLite en mémoire (tests). */ +export async function openInMemoryDatabase(migrationsFolder: string): Promise { + const client = createDbClient("file::memory:"); + const db = createDb(client); + await runMigrations(db, migrationsFolder); + return db; +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..a620876 --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,45 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey({ autoIncrement: true }), + username: text("username").notNull().unique(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +/** + * Session serveur : le token de session (valeur du cookie) n'est JAMAIS stocké + * en clair — uniquement son hash SHA-256. Une fuite de la base ne permet donc + * pas de usurper une session. + */ +export const sessions = sqliteTable("sessions", { + /** SHA-256 hex du token de session (valeur du cookie `seiche_session`) */ + tokenHash: text("token_hash").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + /** Fin de la fenêtre glissante (lastActivityAt + TTL) */ + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + /** Expiration absolue (createdAt + TTL absolu), quel que soit l'activité */ + absoluteExpiryAt: integer("absolute_expiry_at", { mode: "timestamp" }).notNull(), + lastActivityAt: integer("last_activity_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +/** Cache des réponses du service REST externe (clé → JSON) */ +export const externalCache = sqliteTable("external_cache", { + key: text("key").primaryKey(), + value: text("value", { mode: "json" }).notNull(), + fetchedAt: integer("fetched_at", { mode: "timestamp" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), +}); + +export type User = typeof users.$inferSelect; +export type Session = typeof sessions.$inferSelect; +export type ExternalCacheRow = typeof externalCache.$inferSelect; diff --git a/server/src/external/adapters/example.ts b/server/src/external/adapters/example.ts new file mode 100644 index 0000000..a59b9b5 --- /dev/null +++ b/server/src/external/adapters/example.ts @@ -0,0 +1,46 @@ +import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types"; + +const DEMO_ITEMS: ExternalItem[] = [ + { + id: "demo-1", + title: "Exemple : tâche en cours", + subtitle: "Ceci est un élément de démonstration de l'adaptateur « example ».", + status: "running", + }, + { + id: "demo-2", + title: "Exemple : tâche terminée", + subtitle: "Rajoutez ici le détail de ce que votre service REST gère.", + status: "done", + }, + { + id: "demo-3", + title: "Exemple : tâche en erreur", + subtitle: "Le pipeline complet (API → cache → UI) fonctionne avec cet adaptateur.", + status: "error", + }, +]; + +/** + * Adaptateur de démonstration : ne contacte aucun service réel. + * Permet de valider tout le pipeline (auth → API → cache → UI) avant + * d'intégrer le service REST cible. À remplacer/configurer via + * SEICHE_EXTERNAL_ADAPTER=http + variables SEICHE_EXTERNAL_*. + */ +export class ExampleExternalService implements ExternalService { + readonly name = "example"; + + async health(): Promise { + return { + ok: true, + status: 200, + message: "adaptateur de démonstration", + latencyMs: 0, + checkedAt: new Date().toISOString(), + }; + } + + async list(): Promise { + return { items: DEMO_ITEMS, fetchedAt: new Date().toISOString(), fromCache: false }; + } +} diff --git a/server/src/external/adapters/http.ts b/server/src/external/adapters/http.ts new file mode 100644 index 0000000..c9f998d --- /dev/null +++ b/server/src/external/adapters/http.ts @@ -0,0 +1,104 @@ +import { HttpClient } from "../http-client"; +import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types"; + +export interface HttpExternalServiceOptions { + baseUrl: string; + apiKey: string; + healthPath: string; + listPath: string; + timeoutMs: number; + retries: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Adaptateur générique vers une API REST HTTP/HTTPS. + * + * Convention de réponse pour /items (tolérante) : + * - un tableau JSON, ou un objet contenant une clé items/data/results (tableau) + * - chaque élément : id|uuid|slug, title|name|label, description|summary, status|state + * + * À ajuster/étendre quand les détails du service cible sont connus + * (voir docs/service-adapter.md). + */ +export class HttpExternalService implements ExternalService { + readonly name = "http"; + private readonly client: HttpClient; + private readonly options: HttpExternalServiceOptions; + + constructor(options: HttpExternalServiceOptions) { + this.options = options; + this.client = new HttpClient(options); + } + + async health(): Promise { + const startedAt = Date.now(); + try { + const res = await this.client.getJson(this.options.healthPath); + const body = isRecord(res.body) ? res.body : undefined; + return { + ok: res.status >= 200 && res.status < 300, + status: res.status, + message: + (body && typeof body.status === "string" ? body.status : undefined) ?? + (body && typeof body.message === "string" ? body.message : undefined), + latencyMs: res.latencyMs, + checkedAt: new Date().toISOString(), + }; + } catch (err) { + return { + ok: false, + status: 0, + message: err instanceof Error ? err.message : "erreur inconnue", + latencyMs: Date.now() - startedAt, + checkedAt: new Date().toISOString(), + }; + } + } + + async list(): Promise { + const res = await this.client.getJson(this.options.listPath); + if (res.status < 200 || res.status >= 300) { + throw new Error(`Le service externe a répondu HTTP ${res.status}`); + } + const items = normalizeItems(res.body); + return { items, fetchedAt: new Date().toISOString(), fromCache: false }; + } +} + +function normalizeItems(body: unknown): ExternalItem[] { + let array: unknown[] | null = null; + if (Array.isArray(body)) { + array = body; + } else if (isRecord(body)) { + for (const key of ["items", "data", "results", "entries"]) { + const value = body[key]; + if (Array.isArray(value)) { + array = value; + break; + } + } + } + if (array === null) { + throw new Error("Réponse externe inattendue : tableau JSON attendu"); + } + return array.map((element, index) => { + if (!isRecord(element)) { + return { id: String(index), title: String(element), raw: element }; + } + const id = element.id ?? element.uuid ?? element.slug ?? element.key ?? String(index); + const title = element.title ?? element.name ?? element.label ?? element.id ?? String(index); + const subtitle = element.description ?? element.summary ?? element.subtitle; + const status = element.status ?? element.state; + return { + id: String(id), + title: String(title), + subtitle: subtitle !== undefined ? String(subtitle) : undefined, + status: status !== undefined ? String(status) : undefined, + raw: element, + }; + }); +} diff --git a/server/src/external/adapters/index.ts b/server/src/external/adapters/index.ts new file mode 100644 index 0000000..a4ed56f --- /dev/null +++ b/server/src/external/adapters/index.ts @@ -0,0 +1,30 @@ +import type { Config } from "../../config"; +import type { ExternalService } from "../types"; +import { ExampleExternalService } from "./example"; +import { HttpExternalService, type HttpExternalServiceOptions } from "./http"; + +/** Fabrique : instancie l'adaptateur configuré (SEICHE_EXTERNAL_ADAPTER). */ +export function createExternalService(config: Config): ExternalService { + switch (config.external.adapter) { + case "http": { + if (config.external.baseUrl === "") { + throw new Error("SEICHE_EXTERNAL_BASE_URL est requis quand SEICHE_EXTERNAL_ADAPTER=http"); + } + const options: HttpExternalServiceOptions = { + baseUrl: config.external.baseUrl, + apiKey: config.external.apiKey, + healthPath: config.external.healthPath, + listPath: config.external.listPath, + timeoutMs: config.external.timeoutMs, + retries: config.external.retries, + }; + return new HttpExternalService(options); + } + case "example": + return new ExampleExternalService(); + default: { + const exhaustive: never = config.external.adapter; + throw new Error(`Adaptateur externe inconnu : ${String(exhaustive)}`); + } + } +} diff --git a/server/src/external/cache.ts b/server/src/external/cache.ts new file mode 100644 index 0000000..aa8fcf4 --- /dev/null +++ b/server/src/external/cache.ts @@ -0,0 +1,43 @@ +import { eq } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { externalCache } from "../db/schema"; + +/** + * Cache serveur (SQLite) des réponses du service externe. + * Évite de solliciter le service à chaque interaction UI et donne une + * date de fraîcheur cohérente. + */ +export class ExternalCache { + constructor( + private readonly db: Database, + private readonly ttlMs: number, + ) {} + + async get(key: string): Promise { + const rows = await this.db + .select() + .from(externalCache) + .where(eq(externalCache.key, key)) + .limit(1); + const row = rows[0]; + if (!row) return null; + if (new Date() >= row.expiresAt) { + await this.db.delete(externalCache).where(eq(externalCache.key, key)); + return null; + } + return row.value as T; + } + + async set(key: string, value: unknown): Promise { + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.ttlMs); + await this.db + .insert(externalCache) + .values({ key, value, fetchedAt: now, expiresAt }) + .onConflictDoUpdate({ target: externalCache.key, set: { value, fetchedAt: now, expiresAt } }); + } + + async invalidate(key: string): Promise { + await this.db.delete(externalCache).where(eq(externalCache.key, key)); + } +} diff --git a/server/src/external/http-client.ts b/server/src/external/http-client.ts new file mode 100644 index 0000000..8f96efe --- /dev/null +++ b/server/src/external/http-client.ts @@ -0,0 +1,76 @@ +export interface HttpClientOptions { + baseUrl: string; + apiKey: string; + timeoutMs: number; + retries: number; +} + +export class HttpClient { + constructor(private readonly options: HttpClientOptions) {} + + /** + * GET avec timeout et retries (backoff exponentiel + jitter) sur + * erreurs réseau / 5xx. Le body est renvoyé tel quel (JSON parsé + * à la main par l'appelant si nécessaire). + */ + async getJson( + path: string, + headers: Record = {}, + ): Promise<{ status: number; body: unknown; latencyMs: number }> { + const url = new URL(path, this.options.baseUrl); + const allHeaders: Record = { accept: "application/json", ...headers }; + if (this.options.apiKey !== "") { + allHeaders.authorization = `Bearer ${this.options.apiKey}`; + } + + let lastError: unknown = null; + for (let attempt = 0; attempt <= this.options.retries; attempt++) { + const startedAt = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: allHeaders, + signal: controller.signal, + }); + const latencyMs = Date.now() - startedAt; + const text = await res.text(); + let body: unknown = null; + if (text !== "") { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + // On ne retente que sur les erreurs transitoires. + if (res.status >= 500) { + lastError = new Error(`HTTP ${res.status} de ${url.toString()}`); + if (attempt < this.options.retries) { + await this.backoff(attempt); + continue; + } + return { status: res.status, body, latencyMs }; + } + return { status: res.status, body, latencyMs }; + } finally { + clearTimeout(timeout); + } + } catch (err) { + lastError = err; + if (attempt < this.options.retries) { + await this.backoff(attempt); + } + } + } + throw lastError instanceof Error ? lastError : new Error("Échec de la requête externe"); + } + + private backoff(attempt: number): Promise { + const base = 300 * 2 ** attempt; + const delay = base + Math.floor(Math.random() * 100); + return new Promise((resolve) => setTimeout(resolve, delay)); + } +} diff --git a/server/src/external/types.ts b/server/src/external/types.ts new file mode 100644 index 0000000..00d427e --- /dev/null +++ b/server/src/external/types.ts @@ -0,0 +1,32 @@ +export interface ExternalHealth { + ok: boolean; + status: number; + message?: string; + latencyMs: number; + checkedAt: string; +} + +export interface ExternalItem { + id: string; + title: string; + subtitle?: string; + status?: string; + /** Payload brut du service (affiché dans l'UI, utile pour le débogage) */ + raw?: unknown; +} + +export interface ExternalListResult { + items: ExternalItem[]; + fetchedAt: string; + fromCache: boolean; +} + +/** + * Contrat d'un adaptateur vers le service REST externe. + * Chaque implémentation est auto-suffisante et ne connaît que sa config. + */ +export interface ExternalService { + readonly name: string; + health(): Promise; + list(): Promise; +} diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..a4cf740 --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,40 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { buildApp } from "./app"; +import { loadConfig } from "./config"; +import { openDatabase } from "./db/client"; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle"); + +async function main(): Promise { + const config = loadConfig(); + mkdirSync(config.dataDir, { recursive: true }); + + const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER); + const app = await buildApp({ config, db }); + + app.addHook("onClose", async () => { + // libsql : pas de close explicite requis, on garde la connexion vivante + }); + + try { + await app.listen({ port: config.port, host: config.host }); + console.log(`Seiche démarre sur http://${config.host}:${config.port}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } + + const shutdown = async (signal: string) => { + app.log.info(`${signal} reçu, arrêt en cours…`); + await app.close(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} + +main().catch((err) => { + console.error("Échec du démarrage de Seiche :", err); + process.exit(1); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..010ae68 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,98 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { verifyPassword } from "../auth/password"; +import type { RateLimiter } from "../auth/rate-limit"; +import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../auth/session"; +import type { Config } from "../config"; +import type { Database } from "../db/client"; +import { users } from "../db/schema"; +import { parseLoginBody } from "../validation"; + +interface AuthDeps { + db: Database; + config: Config; + loginLimiter: RateLimiter; +} + +export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void { + const { db, config, loginLimiter } = deps; + + app.post("/api/auth/login", async (request, reply) => { + const parsed = parseLoginBody(request.body); + if (!parsed.ok) { + return reply.code(400).send({ error: "invalid_body" }); + } + const { username, password } = parsed.data; + + // Rate limit par IP (réinitialisé à succès) + const ip = request.ip; + if (!loginLimiter.hit(ip)) { + return reply.code(429).send({ error: "too_many_attempts", retryAfterMs: 60_000 }); + } + + const rows = await db.select().from(users).where(eq(users.username, username)).limit(1); + const user = rows[0]; + + // Même message d'erreur que l'échec de vérification (pas d'oracle utilisateur) + const genericFailure = () => reply.code(401).send({ error: "invalid_credentials" }); + if (!user) { + // On exécute quand même une vérification argon2 pour homogénéiser le temps de réponse. + await verifyPassword( + "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + password, + ).catch(() => false); + return genericFailure(); + } + const ok = await verifyPassword(user.passwordHash, password); + if (!ok) return genericFailure(); + + loginLimiter.reset(ip); + const { token, csrfToken } = await app.sessionManager.create(user.id); + const cookieOptions = { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure: false, // TLS géré par Caddy/proxy devant (voir README) + }; + reply.setCookie(SESSION_COOKIE, token, cookieOptions).setCookie(CSRF_COOKIE, csrfToken, { + httpOnly: false, + sameSite: "lax", + path: "/", + secure: false, + }); + return reply.send({ + user: { username: user.username }, + csrfToken, + csrfHeader: CSRF_HEADER, + sessionExpiresAt: new Date(Date.now() + config.sessionTtlMs).toISOString(), + }); + }); + + app.post( + "/api/auth/logout", + { preHandler: [app.authenticate, app.requireCsrf] }, + async (request, reply) => { + const token = request.cookies[SESSION_COOKIE]; + await app.sessionManager.destroy(token); + const clearOptions = { path: "/", sameSite: "lax" as const, secure: false }; + reply + .setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 }) + .setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 }); + return reply.send({ ok: true }); + }, + ); + + app.get("/api/auth/session", async (request, reply) => { + if (!request.session) { + return reply.code(401).send({ error: "not_authenticated" }); + } + return reply.send({ + user: { username: request.session.username }, + // Double-submit : le token CSRF est la valeur du cookie (lisible par le JS), + // qu'on renvoie dans l'en-tête x-csrf-token (vérifié par requireCsrf). + csrfToken: request.cookies[CSRF_COOKIE] ?? null, + csrfHeader: CSRF_HEADER, + sessionExpiresAt: request.session.sessionExpiresAt.toISOString(), + }); + }); +} diff --git a/server/src/routes/external.ts b/server/src/routes/external.ts new file mode 100644 index 0000000..b4a078b --- /dev/null +++ b/server/src/routes/external.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from "fastify"; +import type { ExternalCache } from "../external/cache"; +import type { ExternalListResult, ExternalService } from "../external/types"; + +interface ExternalRoutesDeps { + service: ExternalService; + cache: ExternalCache; +} + +function cacheKey(serviceName: string, op: string): string { + return `${serviceName}:${op}`; +} + +export function buildExternalRoutes(app: FastifyInstance, deps: ExternalRoutesDeps): void { + const { service, cache } = deps; + const keyItems = cacheKey(service.name, "items"); + + app.get("/api/external/health", { preHandler: [app.authenticate] }, async (_request, reply) => { + const health = await service.health(); + return reply.send(health); + }); + + app.get("/api/external/items", { preHandler: [app.authenticate] }, async (_request, reply) => { + const cached = await cache.get(keyItems); + if (cached) { + return reply.send({ ...cached, fromCache: true }); + } + const result = await service.list(); + await cache.set(keyItems, result); + return reply.send(result); + }); + + app.post( + "/api/external/items/refresh", + { preHandler: [app.authenticate, app.requireCsrf] }, + async (_request, reply) => { + await cache.invalidate(keyItems); + const result = await service.list(); + await cache.set(keyItems, result); + return reply.send({ ...result, fromCache: false }); + }, + ); +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..c31a6a2 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,7 @@ +import type { FastifyInstance } from "fastify"; + +export function buildHealthRoutes(app: FastifyInstance): void { + app.get("/api/health", async (_request, reply) => { + return reply.send({ status: "ok", uptimeSec: Math.round(process.uptime()) }); + }); +} diff --git a/server/src/setup.ts b/server/src/setup.ts new file mode 100644 index 0000000..02a16e0 --- /dev/null +++ b/server/src/setup.ts @@ -0,0 +1,52 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { sql } from "drizzle-orm"; +import { hashPassword } from "./auth/password"; +import { loadConfig } from "./config"; +import { openDatabase } from "./db/client"; +import { users } from "./db/schema"; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle"); + +/** + * Crée le compte initial (admin) dans une base vide. + * Idempotent : si des comptes existent déjà, rien n'est modifié. + * Variables requises (voir .env.example) : SEICHE_ADMIN_USERNAME, SEICHE_ADMIN_PASSWORD. + */ +async function main(): Promise { + const config = loadConfig(); + mkdirSync(config.dataDir, { recursive: true }); + const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER); + + const [row] = await db.select({ count: sql`count(*)` }).from(users); + const existing = row?.count ?? 0; + if (existing > 0) { + console.log( + `Base ${config.dataDir}/seiche.db : ${existing} compte(s) déjà présent(s), rien à faire.`, + ); + return; + } + + const username = process.env.SEICHE_ADMIN_USERNAME; + const password = process.env.SEICHE_ADMIN_PASSWORD; + if (!username || !password) { + throw new Error( + "Base vide : définir SEICHE_ADMIN_USERNAME et SEICHE_ADMIN_PASSWORD (voir .env.example) puis relancer pnpm setup.", + ); + } + if (password.length < 12) { + throw new Error("SEICHE_ADMIN_PASSWORD doit faire au moins 12 caractères."); + } + + const passwordHash = await hashPassword(password); + await db.insert(users).values({ username, passwordHash }); + console.log(`Compte initial « ${username} » créé (base : ${config.dataDir}/seiche.db).`); +} + +main().catch((err) => { + console.error( + "Échec de la création du compte initial :", + err instanceof Error ? err.message : err, + ); + process.exit(1); +}); diff --git a/server/src/validation.ts b/server/src/validation.ts new file mode 100644 index 0000000..b424f65 --- /dev/null +++ b/server/src/validation.ts @@ -0,0 +1,24 @@ +/** Validation minimaliste des corps de requête (sans dépendance externe). */ + +export interface LoginBody { + username: string; + password: string; +} + +export function parseLoginBody(body: unknown): { ok: true; data: LoginBody } | { ok: false } { + if (typeof body !== "object" || body === null) return { ok: false }; + const record = body as Record; + const username = record.username; + const password = record.password; + if ( + typeof username !== "string" || + typeof password !== "string" || + username.length === 0 || + username.length > 64 || + password.length === 0 || + password.length > 256 + ) { + return { ok: false }; + } + return { ok: true, data: { username, password } }; +} diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts new file mode 100644 index 0000000..55c54da --- /dev/null +++ b/server/tests/api.test.ts @@ -0,0 +1,210 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app"; +import { hashPassword } from "../src/auth/password"; +import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../src/auth/session"; +import type { Database } from "../src/db/client"; +import { users } from "../src/db/schema"; +import { makeConfig, openTestDb } from "./helpers"; + +// Tests : pas de worker pino-pretty, error-handler en mode production. +process.env.NODE_ENV = "production"; +process.env.SEICHE_LOG_LEVEL = "silent"; + +let db: Database; +let app: FastifyInstance; + +beforeEach(async () => { + db = await openTestDb(); + app = await buildApp({ config: makeConfig(), db }); +}); + +afterEach(async () => { + await app.close(); +}); + +function asSetCookieList(raw: unknown): string[] { + if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string"); + if (raw === undefined) return []; + return [String(raw)]; +} + +function cookieValue(raw: unknown, name: string): string | undefined { + for (const item of asSetCookieList(raw)) { + const pair = item.split(";")[0] ?? ""; + if (pair.startsWith(`${name}=`)) return pair.slice(name.length + 1); + } + return undefined; +} + +function cookieHeader(raw: unknown, name: string): string | undefined { + return asSetCookieList(raw).find((item) => item.startsWith(`${name}=`)); +} + +async function seedUser(username: string, password: string): Promise { + await db.insert(users).values({ username, passwordHash: await hashPassword(password) }); +} + +describe("API /api (buildApp)", () => { + it("GET /api/health → 200 ok", async () => { + const res = await app.inject({ method: "GET", url: "/api/health" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok", uptimeSec: expect.any(Number) }); + }); + + it("POST /api/auth/login : succès → 200, cookies httpOnly + csrfToken", async () => { + await seedUser("alice", "correct-horse-battery"); + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "correct-horse-battery" }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { + user: { username: string }; + csrfToken: string; + csrfHeader: string; + }; + expect(body.user.username).toBe("alice"); + expect(body.csrfToken).toMatch(/^[a-f0-9]{64}$/); + expect(body.csrfHeader).toBe(CSRF_HEADER); + const raw = res.headers["set-cookie"]; + expect(cookieValue(raw, SESSION_COOKIE)).toMatch(/^[a-f0-9]{64}$/); + expect(cookieValue(raw, CSRF_COOKIE)).toBe(body.csrfToken); + expect(cookieHeader(raw, SESSION_COOKIE)).toContain("HttpOnly"); + expect(cookieHeader(raw, SESSION_COOKIE)).toContain("SameSite=Lax"); + expect(cookieHeader(raw, CSRF_COOKIE)).not.toContain("HttpOnly"); + }); + + it("POST /api/auth/login : mauvais mdp ou user inconnu → 401 identique", async () => { + await seedUser("alice", "correct-horse-battery"); + const wrong = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "nope" }, + }); + expect(wrong.statusCode).toBe(401); + expect(wrong.json()).toEqual({ error: "invalid_credentials" }); + const unknown = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "ghost", password: "nope" }, + }); + expect(unknown.statusCode).toBe(401); + expect(unknown.json()).toEqual({ error: "invalid_credentials" }); + }); + + it("POST /api/auth/login : corps invalide → 400 invalid_body", async () => { + const empty = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} }); + expect(empty.statusCode).toBe(400); + expect(empty.json()).toEqual({ error: "invalid_body" }); + const missing = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice" }, + }); + expect(missing.statusCode).toBe(400); + }); + + it("POST /api/auth/login : 6e tentative dans la minute → 429 (rate limit par IP)", async () => { + await seedUser("alice", "correct-horse-battery"); + for (let i = 0; i < 5; i++) { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "wrong" }, + }); + expect(res.statusCode).toBe(401); + } + const blocked = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "correct-horse-battery" }, + }); + expect(blocked.statusCode).toBe(429); + expect((blocked.json() as { error: string }).error).toBe("too_many_attempts"); + }); + + it("POST /api/auth/logout sans session → 401 not_authenticated", async () => { + const res = await app.inject({ method: "POST", url: "/api/auth/logout" }); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: "not_authenticated" }); + }); + + it("CSRF double-submit : 403 sans/mauvais header, 200 avec le bon, session détruite", async () => { + await seedUser("alice", "correct-horse-battery"); + const login = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "alice", password: "correct-horse-battery" }, + }); + expect(login.statusCode).toBe(200); + const body = login.json() as { csrfToken: string }; + const session = cookieValue(login.headers["set-cookie"], SESSION_COOKIE); + const csrf = cookieValue(login.headers["set-cookie"], CSRF_COOKIE); + if (session === undefined || csrf === undefined) { + throw new Error("cookies de session manquants"); + } + const cookies = { [SESSION_COOKIE]: session, [CSRF_COOKIE]: csrf }; + + const noHeader = await app.inject({ method: "POST", url: "/api/auth/logout", cookies }); + expect(noHeader.statusCode).toBe(403); + expect(noHeader.json()).toEqual({ error: "csrf_invalid" }); + + const wrongHeader = await app.inject({ + method: "POST", + url: "/api/auth/logout", + cookies, + headers: { [CSRF_HEADER]: "0".repeat(64) }, + }); + expect(wrongHeader.statusCode).toBe(403); + + const ok = await app.inject({ + method: "POST", + url: "/api/auth/logout", + cookies, + headers: { [CSRF_HEADER]: body.csrfToken }, + }); + expect(ok.statusCode).toBe(200); + expect(ok.json()).toEqual({ ok: true }); + expect(cookieValue(ok.headers["set-cookie"], SESSION_COOKIE)).toBe(""); + + const after = await app.inject({ method: "GET", url: "/api/auth/session", cookies }); + expect(after.statusCode).toBe(401); + }); + + it("error-handler : exception de route → 500 internal_error, sans fuite (prod)", async () => { + app.post("/api/test-crash", async () => { + throw new Error("boom interne"); + }); + const res = await app.inject({ method: "POST", url: "/api/test-crash" }); + expect(res.statusCode).toBe(500); + const body = res.json() as { error: string; details?: string }; + expect(body.error).toBe("internal_error"); + expect(body.details).toBeUndefined(); + }); + + it("DB round-trip : insert → read → update → delete", async () => { + const hash = await hashPassword("roundtrip"); + const inserted = await db + .insert(users) + .values({ username: "bob", passwordHash: hash }) + .returning(); + const user = inserted[0]; + if (user === undefined) throw new Error("insertion ratée"); + + const [found] = await db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(found?.username).toBe("bob"); + expect(found?.passwordHash).toBe(hash); + + const newHash = await hashPassword("roundtrip2"); + await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id)); + const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(updated?.passwordHash).toBe(newHash); + + await db.delete(users).where(eq(users.id, user.id)); + const remaining = await db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(remaining).toHaveLength(0); + }); +}); diff --git a/server/tests/helpers.ts b/server/tests/helpers.ts new file mode 100644 index 0000000..e586733 --- /dev/null +++ b/server/tests/helpers.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import type { Config } from "../src/config"; +import { type Database, openInMemoryDatabase } from "../src/db/client"; + +export const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle"); + +export function makeConfig(overrides: Partial = {}): Config { + return { + port: 8787, + host: "127.0.0.1", + dataDir: "/tmp/seiche-tests", + sessionTtlMs: 8 * 60 * 60 * 1000, + sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000, + maxLoginAttemptsPerMinute: 5, + publicDir: null, + external: { + adapter: "example", + baseUrl: "", + apiKey: "", + healthPath: "/health", + listPath: "/items", + timeoutMs: 10_000, + retries: 2, + cacheTtlMs: 30_000, + }, + ...overrides, + }; +} + +/** Base SQLite in-memory avec les migrations jouées. */ +export async function openTestDb(): Promise { + return openInMemoryDatabase(MIGRATIONS_FOLDER); +} diff --git a/server/tests/password.test.ts b/server/tests/password.test.ts new file mode 100644 index 0000000..806adcf --- /dev/null +++ b/server/tests/password.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { hashPassword, verifyPassword } from "../src/auth/password"; + +describe("hashPassword / verifyPassword (argon2id)", () => { + it("roundtrip", async () => { + const hash = await hashPassword("correct horse battery staple"); + expect(hash).toMatch(/^\$argon2id\$/); + expect(await verifyPassword(hash, "correct horse battery staple")).toBe(true); + }); + + it("mauvais mot de passe → false", async () => { + const hash = await hashPassword("correct horse battery staple"); + expect(await verifyPassword(hash, "nope")).toBe(false); + }); + + it("hash invalide → false (jamais d'exception)", async () => { + expect(await verifyPassword("$argon2id$garbage", "x")).toBe(false); + expect(await verifyPassword("", "x")).toBe(false); + }); + + it("salé : même mot de passe → hashes différents", async () => { + const a = await hashPassword("même mot de passe"); + const b = await hashPassword("même mot de passe"); + expect(a).not.toBe(b); + expect(await verifyPassword(a, "même mot de passe")).toBe(true); + expect(await verifyPassword(b, "même mot de passe")).toBe(true); + }); +}); diff --git a/server/tests/rate-limit.test.ts b/server/tests/rate-limit.test.ts new file mode 100644 index 0000000..9589c72 --- /dev/null +++ b/server/tests/rate-limit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { RateLimiter } from "../src/auth/rate-limit"; + +const T0 = new Date("2026-01-01T00:00:00Z"); +const at = (ms: number) => new Date(T0.getTime() + ms); + +describe("RateLimiter (fenêtre glissante)", () => { + it("autorise jusqu'à maxHits puis bloque", () => { + const rl = new RateLimiter(3, 60_000); + expect(rl.hit("a", T0)).toBe(true); + expect(rl.hit("a", at(1000))).toBe(true); + expect(rl.hit("a", at(2000))).toBe(true); + expect(rl.hit("a", at(3000))).toBe(false); + }); + + it("les hits anciens sortent de la fenêtre", () => { + const rl = new RateLimiter(2, 60_000); + expect(rl.hit("a", T0)).toBe(true); + expect(rl.hit("a", at(30_000))).toBe(true); + expect(rl.hit("a", at(31_000))).toBe(false); + // Le hit de T0 a expiré de la fenêtre (60 s). + expect(rl.hit("a", at(61_001))).toBe(true); + }); + + it("reset vide la clé", () => { + const rl = new RateLimiter(1, 60_000); + expect(rl.hit("a", T0)).toBe(true); + expect(rl.hit("a", at(1000))).toBe(false); + rl.reset("a"); + expect(rl.hit("a", at(2000))).toBe(true); + }); + + it("remaining reflète les tentatives restantes", () => { + const rl = new RateLimiter(2, 60_000); + expect(rl.remaining("a", T0)).toBe(2); + rl.hit("a", T0); + expect(rl.remaining("a", at(1000))).toBe(1); + }); + + it("les clés sont isolées", () => { + const rl = new RateLimiter(1, 60_000); + expect(rl.hit("a", T0)).toBe(true); + expect(rl.hit("b", T0)).toBe(true); + }); +}); diff --git a/server/tests/session.test.ts b/server/tests/session.test.ts new file mode 100644 index 0000000..7f24a62 --- /dev/null +++ b/server/tests/session.test.ts @@ -0,0 +1,125 @@ +import { createHash, randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "vitest"; +import { SessionManager } from "../src/auth/session"; +import type { Database } from "../src/db/client"; +import { sessions, users } from "../src/db/schema"; +import { makeConfig, openTestDb } from "./helpers"; + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function newToken(): string { + return randomBytes(32).toString("hex"); +} + +describe("SessionManager", () => { + let db: Database; + let manager: SessionManager; + let userId: number; + + beforeAll(async () => { + db = await openTestDb(); + manager = new SessionManager(db, makeConfig()); + const inserted = await db + .insert(users) + .values({ username: "alice", passwordHash: "x" }) + .returning(); + const user = inserted[0]; + if (user === undefined) throw new Error("utilisateur de test non inséré"); + userId = user.id; + }); + + it("create : token + CSRF 256 bits, getValid renvoie le contexte", async () => { + const { token, csrfToken } = await manager.create(userId); + expect(token).toMatch(/^[a-f0-9]{64}$/); + expect(csrfToken).toMatch(/^[a-f0-9]{64}$/); + const ctx = await manager.getValid(token); + expect(ctx?.userId).toBe(userId); + expect(ctx?.username).toBe("alice"); + expect(ctx?.sessionExpiresAt).toBeInstanceOf(Date); + }); + + it("getValid : token absent ou inconnu → null", async () => { + expect(await manager.getValid(undefined)).toBeNull(); + expect(await manager.getValid(newToken())).toBeNull(); + }); + + it("le token est stocké haché (pas en clair)", async () => { + const { token } = await manager.create(userId); + const rows = await db + .select() + .from(sessions) + .where(eq(sessions.tokenHash, sha256Hex(token))); + expect(rows).toHaveLength(1); + expect(rows[0]?.tokenHash).not.toBe(token); + }); + + it("TTL glissant : expiration = maintenant + ttl", async () => { + const { token } = await manager.create(userId); + const before = Date.now(); + const ctx = await manager.getValid(token); + expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 8 * 3600 * 1000 - 2000); + expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 8 * 3600 * 1000 + 5000); + }); + + it("TTL glissant borné par l'expiration absolue", async () => { + const m = new SessionManager( + db, + makeConfig({ sessionTtlMs: 3_600_000, sessionAbsoluteTtlMs: 300_000 }), + ); + const { token } = await m.create(userId); + const before = Date.now(); + const ctx = await m.getValid(token); + expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 300_000 - 1_000); + expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 300_000 + 1_000); + }); + + it("expiration absolue dépassée → null + ligne supprimée", async () => { + const token = newToken(); + const past = new Date(Date.now() - 1000); + await db.insert(sessions).values({ + tokenHash: sha256Hex(token), + userId, + createdAt: past, + expiresAt: past, + absoluteExpiryAt: past, + lastActivityAt: past, + }); + expect(await manager.getValid(token)).toBeNull(); + const rows = await db + .select() + .from(sessions) + .where(eq(sessions.tokenHash, sha256Hex(token))); + expect(rows).toHaveLength(0); + }); + + it("destroy invalidate le token", async () => { + const { token } = await manager.create(userId); + await manager.destroy(token); + expect(await manager.getValid(token)).toBeNull(); + }); + + it("5 sessions max par utilisateur (exactement 5 des 6 restent valides)", async () => { + const inserted = await db + .insert(users) + .values({ username: "bob", passwordHash: "x" }) + .returning(); + const bob = inserted[0]; + if (bob === undefined) throw new Error("utilisateur bob non inséré"); + const m2 = new SessionManager(db, makeConfig()); + const tokens: string[] = []; + for (let i = 0; i < 6; i += 1) { + tokens.push((await m2.create(bob.id)).token); + } + const rows = await db.select().from(sessions).where(eq(sessions.userId, bob.id)); + expect(rows).toHaveLength(5); + let validCount = 0; + for (const t of tokens) { + const ctx = await m2.getValid(t); + if (ctx !== null) validCount += 1; + } + expect(validCount).toBe(5); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..d021391 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"], + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests", "drizzle.config.ts"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..051e657 --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 20000, + hookTimeout: 20000, + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..8265d53 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "useDefineForClassFields": true + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..240e814 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Séiche + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..638d503 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "seiche-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "jsdom": "^26.1.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.9.3", + "vite": "^7.0.0", + "vitest": "^4.1.11" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..183fda2 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,21 @@ +import { Navigate, Route, Routes } from "react-router"; +import RequireAuth from "./components/RequireAuth"; +import DashboardPage from "./pages/DashboardPage"; +import LoginPage from "./pages/LoginPage"; + +export default function App() { + return ( + + } /> + + + + } + /> + } /> + + ); +} diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx new file mode 100644 index 0000000..8ca26d5 --- /dev/null +++ b/web/src/components/RequireAuth.tsx @@ -0,0 +1,26 @@ +import { type ReactNode, useEffect } from "react"; +import { useNavigate } from "react-router"; +import { useSession } from "../lib/session"; + +/** + * Garde de route : sans session valide, redirect `/login`. + * Pendant la vérification, un écran de chargement (pas de flash de contenu protégé). + */ +export default function RequireAuth({ children }: { children: ReactNode }) { + const { isPending, isError } = useSession(); + const navigate = useNavigate(); + + useEffect(() => { + if (isError) navigate("/login", { replace: true }); + }, [isError, navigate]); + + if (isPending) { + return ( +
+

Chargement…

+
+ ); + } + if (isError) return null; + return <>{children}; +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..b84f4b9 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,79 @@ +/** + * Client API minimal (fetch + cookies). + * Contrat serveur : server/src/routes/auth.ts (mêmes codes d'erreur, mêmes shapes). + */ + +export interface ApiUser { + username: string; +} + +export interface AuthSession { + user: ApiUser; + csrfToken: string; + csrfHeader: string; + sessionExpiresAt: string; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + public readonly retryAfterMs?: number, + ) { + super(code); + } +} + +/** Clé TanStack Query du cache de session (partagée entre login et guard). */ +export const sessionQueryKey = ["session"] as const; + +interface ErrorBody { + error?: unknown; + retryAfterMs?: unknown; +} + +async function request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + ...init, + credentials: "same-origin", + headers: { "content-type": "application/json", ...init.headers }, + }); + + let body: ErrorBody | T = {}; + try { + body = (await res.json()) as ErrorBody | T; + } catch { + // corps vide / non JSON : on reste sur {} + } + + if (!res.ok) { + const record = body as ErrorBody; + const retryAfterMs = typeof record.retryAfterMs === "number" ? record.retryAfterMs : undefined; + throw new ApiError( + res.status, + typeof record.error === "string" ? record.error : "unknown", + retryAfterMs, + ); + } + return body as T; +} + +export const api = { + login(username: string, password: string): Promise { + return request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + }, + + session(): Promise { + return request("/api/auth/session"); + }, + + logout(session: AuthSession): Promise<{ ok: true }> { + return request<{ ok: true }>("/api/auth/logout", { + method: "POST", + headers: { [session.csrfHeader]: session.csrfToken }, + }); + }, +}; diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts new file mode 100644 index 0000000..f72667b --- /dev/null +++ b/web/src/lib/queryClient.ts @@ -0,0 +1,17 @@ +import { QueryClient } from "@tanstack/react-query"; +import { ApiError } from "./api"; + +/** Factory partagée main.tsx ↔ tests : mêmes options par défaut des queries. */ +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // Pas de retry sur les 4xx (401 session morte, 400 corps invalide…) : ça ne passera pas. + retry: (failureCount, error) => + !(error instanceof ApiError && error.status >= 400 && error.status < 500) && + failureCount < 3, + }, + }, + }); +} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts new file mode 100644 index 0000000..a584639 --- /dev/null +++ b/web/src/lib/session.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { type AuthSession, api, sessionQueryKey } from "./api"; + +/** + * Session courante (cache partagé : la login seede, le guard lit, le dashboard consomme). + */ +export function useSession(): ReturnType> { + return useQuery({ + queryKey: sessionQueryKey, + queryFn: api.session, + }); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..cafd38d --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,22 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router"; +import App from "./App"; +import { createQueryClient } from "./lib/queryClient"; +import "./index.css"; + +const queryClient = createQueryClient(); + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("élément #root introuvable"); + +createRoot(rootElement).render( + + + + + + + , +); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..ebbcbbf --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -0,0 +1,45 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { api, sessionQueryKey } from "../lib/api"; +import { useSession } from "../lib/session"; + +export default function DashboardPage() { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + // Le guard garantit une session valide ici ; null = état transitoire impossible. + if (!session) return null; + const s = session; // const resserré : accessible depuis le closure sans renarrowing + + async function handleLogout() { + try { + await api.logout(s); + } catch { + // Session déjà morte côté serveur : on continue la déconnexion locale. + } + queryClient.removeQueries({ queryKey: sessionQueryKey }); + navigate("/login"); + } + + return ( +
+
+

Tableau de bord — Séiche

+

+ Connecté en tant que {session.user.username} +

+

+ Session expire le {new Date(session.sessionExpiresAt).toLocaleString("fr-FR")}. +

+ +
+
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2dea066 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,101 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type FormEvent, useState } from "react"; +import { useNavigate } from "react-router"; +import { ApiError, api, sessionQueryKey } from "../lib/api"; + +function errorMessage(err: unknown): string { + if (err instanceof ApiError) { + if (err.code === "invalid_credentials") return "Identifiants invalides."; + if (err.code === "too_many_attempts") { + const s = Math.ceil((err.retryAfterMs ?? 60_000) / 1000); + return `Trop de tentatives, réessayez dans ${s} s.`; + } + if (err.status === 429) return "Trop de tentatives, patientez un instant."; + if (err.status >= 500) return "Erreur serveur, réessayez plus tard."; + } + return "Connexion impossible, réessayez."; +} + +export default function LoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [formError, setFormError] = useState(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + api.login(username, password), + onSuccess: (session) => { + queryClient.setQueryData(sessionQueryKey, session); + navigate("/"); + }, + onError: (err) => setFormError(errorMessage(err)), + }); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + if (mutation.isPending) return; + if (username.length < 1 || username.length > 64) { + setFormError("Nom d'utilisateur : entre 1 et 64 caractères."); + return; + } + if (password.length < 1 || password.length > 256) { + setFormError("Mot de passe : entre 1 et 256 caractères."); + return; + } + mutation.mutate({ username, password }); + } + + return ( +
+
+

Connexion — Séiche

+ + + + + + {formError !== null && ( +

+ {formError} +

+ )} + + +
+
+ ); +} diff --git a/web/tests/setup.ts b/web/tests/setup.ts new file mode 100644 index 0000000..972dda8 --- /dev/null +++ b/web/tests/setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// Auto-cleanup désactivé (vitest sans `globals: true`) → cleanup explicite. +afterEach(() => { + cleanup(); +}); diff --git a/web/tests/smoke.test.tsx b/web/tests/smoke.test.tsx new file mode 100644 index 0000000..d0341bf --- /dev/null +++ b/web/tests/smoke.test.tsx @@ -0,0 +1,91 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import App from "../src/App"; +import type { AuthSession } from "../src/lib/api"; +import { createQueryClient } from "../src/lib/queryClient"; + +const session: AuthSession = { + user: { username: "nicolas" }, + csrfToken: "csrf-token", + csrfHeader: "x-csrf-token", + sessionExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function mockApi(handler: (url: string, init?: RequestInit) => Response) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init)), + ); +} + +function renderApp(path: string) { + return render( + + + + + , + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("smoke front", () => { + it("rend le formulaire de login", () => { + mockApi(() => jsonResponse({})); + renderApp("/login"); + + expect(screen.getByLabelText(/nom d'utilisateur/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/mot de passe/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument(); + }); + + it("redirige vers /login sans session valide", async () => { + mockApi((url) => + url.includes("/auth/session") + ? jsonResponse({ error: "not_authenticated" }, 401) + : jsonResponse({}, 404), + ); + renderApp("/"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument(); + }); + }); + + it("connecte l'utilisateur et affiche le dashboard", async () => { + mockApi((url, init) => + url.includes("/auth/login") && init?.method === "POST" + ? jsonResponse(session) + : jsonResponse({ error: "not_authenticated" }, 401), + ); + renderApp("/login"); + + fireEvent.change(screen.getByLabelText(/nom d'utilisateur/i), { + target: { value: "nicolas" }, + }); + fireEvent.change(screen.getByLabelText(/mot de passe/i), { + target: { value: "secret" }, + }); + const form = screen + .getByRole("button", { name: /se connecter/i }) + .closest("form") as HTMLFormElement; + fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/tableau de bord/i)).toBeInTheDocument(); + }); + expect(screen.getByText("nicolas")).toBeInTheDocument(); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3a8b2ab --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..75d1d66 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,23 @@ +/// +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + // Dev : l'API tourne sur le port du serveur (défaut 8787, voir server/src/config.ts). + "/api": "http://localhost:8787", + }, + }, + build: { + outDir: "dist", + }, + test: { + environment: "jsdom", + setupFiles: "./tests/setup.ts", + passWithNoTests: true, + }, +});