diff --git a/README.md b/README.md new file mode 100644 index 0000000..3275579 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Seiche + +Appli web auto-hébergée, **réseau local uniquement, mono-utilisateur** : gère un service externe via son API REST, protégée par mot de passe. + +**Stack** : TypeScript strict · Node 22 + Fastify 5 · SQLite (libsql + Drizzle) · Vite + React 19 + Tailwind 4 · 1 conteneur (API + front statique). + +- Doc de projet / décisions / plan : [REPRISE.md](REPRISE.md) +- Détail backend : [server/README.md](server/README.md) · Détail front : [web/README.md](web/README.md) + +## Prérequis + +- Node ≥ 22, pnpm (workspace) +- Docker + compose (pour la prod) + +## Démarrage en développement + +```bash +pnpm install +pnpm dev:server # terminal 1 — API sur http://localhost:8787 +pnpm dev:web # terminal 2 — Vite sur http://localhost:5173 (proxy /api → 8787) +``` + +Premier compte (nécessaire une seule fois, idempotent) : + +```bash +SEICHE_ADMIN_USERNAME=admin SEICHE_ADMIN_PASSWORD='<≥12 caractères>' pnpm setup +``` + +Puis ouvrir http://localhost:5173 et se connecter. + +## Démarrage en production (Docker) + +```bash +docker compose up -d --build + +# Premier compte (dans le conteneur, base vide) : +docker compose exec seiche sh -c \ + "SEICHE_ADMIN_USERNAME=admin SEICHE_ADMIN_PASSWORD='<≥12 caractères>' pnpm setup" +``` + +Le conteneur écoute `8787` (API + front buildé, servi par Fastify). La base SQLite persiste dans le volume `seiche-data` (`/data`). Healthcheck : `GET /api/health`. + +## Variables d'environnement + +Référent complet : [.env.example](.env.example) (copier dans `.env` pour la dev — **jamais committer le `.env`**). + +| Variable | Défaut | Rôle | +|---|---|---| +| `SEICHE_PORT` / `SEICHE_HOST` | `8787` / `127.0.0.1` | Écoute du serveur (Docker force `0.0.0.0`) | +| `SEICHE_DATA_DIR` | `./data` | Dossier SQLite (volume `/data` en Docker) | +| `SEICHE_SESSION_TTL_MS` | 8 h | Durée de vie d'une session | +| `SEICHE_SESSION_ABSOLUTE_TTL_MS` | 7 j | Durée absolue max | +| `SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE` | `5` | Rate-limit `/login` par IP | +| `SEICHE_COOKIE_SECURE` | `0` | `1` derrière un proxy TLS (cookies `Secure`) | +| `SEICHE_TRUST_PROXY` | `0` | `1` derrière un proxy (lit `X-Forwarded-For`) | +| `SEICHE_EXTERNAL_*` | `example` | Service REST externe (adaptateur, URL, clé, cache…) | + +## Sauvegarde + +```bash +./scripts/backup.sh [dir_sortie] # défaut : ./backups/ +``` + +Snapshot cohérente **en ligne** (`VACUUM INTO` dans le conteneur, le serveur continue de servir) → copie **hors** du volume → vérification de l'en-tête SQLite. Prérequis : conteneur en marche. Penser à cronner et à stocker les `.db` ailleurs que sur la machine (le dossier `backups/` est gitignoré). + +## Sécurité — non négociables + +- Mot de passe **Argon2id** (sel par compte), jamais en clair ni dans les logs. +- **Rate-limit** sur `/login` (anti brute-force, par IP client). +- Session : cookie **httpOnly + SameSite=Lax**, durée limitée, révocation au logout ; **CSRF** (double-submit) sur toutes les mutations. +- Secrets uniquement en variables d'environnement ; SQLite hors de l'image (volume + backup). +- **LAN sans TLS = mots de passe en clair sur le réseau.** À trancher (REPRISE.md §5 q4) : Caddy/Tailscale ou HTTP assumé. Derrière un proxy TLS : `SEICHE_COOKIE_SECURE=1` + `SEICHE_TRUST_PROXY=1`. +- API minimale : `/api/health` public, tout le reste protégé. + +## Structure + +``` +seiche/ +├── server/ # Fastify : routes /api/v1/*, auth, DB, client externe +├── web/ # Vite + React : login, dashboard, client API +├── scripts/ # backup.sh +└── docker-compose.yml · Dockerfile · .env.example +``` diff --git a/REPRISE.md b/REPRISE.md index 3db8c18..dc1d4c7 100644 --- a/REPRISE.md +++ b/REPRISE.md @@ -1,6 +1,6 @@ # Projet « Seiche » — doc de reprise -> À jour : 2026-08-26 (A1–D4 + proxy ✅ + git) · Statut : **stack complète validée** : serveur 30 tests, front 3 tests + build, Docker build+up, admin créé, `master` poussé sur Gitea · Reste : READMEs (E1), TLS LAN + VPS (décision §5) +> À jour : 2026-08-26 (A1–D4 + proxy + READMEs ✅, git) · Statut : **stack complète validée** : serveur 30 tests, front 3 tests + build, Docker build+up, admin créé, `master` poussé sur Gitea · Reste : TLS LAN + VPS (décision §5) > 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**. @@ -91,7 +91,7 @@ seiche/ - [x] `/api/v1/health` public, le reste protégé (401 sans session, 200 avec — testé) - [x] Tests verts (Vitest) : 26 tests serveur + 3 tests front, tous OK - [x] Biome + `tsc --noEmit` clean (les 2 paquets + tsc web) -- [ ] READMEs (E1) : racine + server + web — dev, prod, env, backup, sécurité (à créer) +- [x] READMEs (E1) : racine + server + web — dev, prod, env, backup, sécurité (créés 2026-08-26) ## 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. @@ -118,18 +118,19 @@ seiche/ | 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. | +| 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 :5173 → HTTP 200. | ### Journal des sessions 2026-08-25 | Tâche | Résultat | | --- | --- | | C2 : tests serveur | **✅ 26 tests / 7 fichiers** : rate-limit (429 au 5e), api (401/200/401), session (httpOnly), password (argon2id), auth (login/logout/me, CSRF, 400). 1er run : 4 échecs — `fetch` n'interceptait pas `localhost` (Node 20) → helper `tests/http.ts` avec regex IP/localhost. Suite verte. | -| D1 : front | **✅ scaffold web/ + pages** : LoginPage (email/mot de passe/erreurs), RequireAuth (redirect /login), DashboardPage (api-client → login/logout), client API (fetch relatif, httpOnly), queryClient (mutate login/logout). **3 tests** (App render, RequireAuth login→redirect, queryClient) + `tsc` + `vite build` OK. Port 5199, proxy Vite → 8787. | +| D1 : front | **✅ scaffold web/ + pages** : LoginPage (email/mot de passe/erreurs), RequireAuth (redirect /login), DashboardPage (api-client → login/logout), client API (fetch relatif, httpOnly), queryClient (mutate login/logout). **3 tests** (App render, RequireAuth login→redirect, queryClient) + `tsc` + `vite build` OK. Port 5173 (corrigé 2026-08-26), proxy Vite → 8787. | | D2 : `pnpm dev` | **✅** : `concurrently` serveur + Vite ; login navigateur OK, dashboard, déconnexion → /login. | | D3 : Docker | **✅** : Dockerfile multi-stage (deps → build → node:22-slim, `nonroot`, healthcheck) ; image 235 Mo, build ~30 s ; compose port **8787** (127.0.0.1), volume `seiche-data` ; données persistées (reboot → admin toujours là) ; `scripts/backup.sh` testé. | | Git + Gitea | **✅** : `git init` `master`, commit `581f7e1` (69 fichiers) poussé sur `gitea-lab:nicolas/seiche` (compte `bot-claude`, clé `id_ed25519_gitea_bot_claude`). `.gitignore` corrigé (`.env` uniquement, pas `.env.example`). | | E4 : Proxy (2026-08-26) | **✅** : `SEICHE_TRUST_PROXY` (Fastify `trustProxy` → rate-limit par IP client derrière proxy) + `SEICHE_COOKIE_SECURE` (cookies Secure derrière TLS). 4 tests (`proxy.test.ts`) : 6 IPs distinctes limitées séparément, XFF ignoré par défaut, Secure on/off. Suite : 30/30. Session précédente interrompue sur cette tâche ; finalisée et commitée. | +| E1 : READMEs (2026-08-26) | **✅** : README racine (démarrage dev/Docker, env, backup, sécurité) + `server/README.md` (scripts, migrations au boot, structure, API) + `web/README.md` (scripts, structure, contrat API). Corrections doc : port dev Vite = 5173 (pas 5199), health = `/api/health` (pas `/api/v1/health`). | **Décision (2026-08-25, Nicolas)** : en prod, **1 seul VPS** (pas encore provisionné — q12 §5) ; **développement en local réseau (LAN)** jusqu'à la fin du développement ; **aucun compte Google/external** (q11 §5) — le scaffold `external/` est un futur point d'extension, la doc se limite à une route auth. **Reste (E)** : READMEs racine/server/web (E1), TLS LAN + VPS (après décision §5). @@ -155,7 +156,7 @@ seiche/ - [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é) +- [x] E1. README.md (dev, prod, env, backup, sécurité) → **fait (2026-08-26)** : README racine + `server/README.md` + `web/README.md` - [x] E2. ~~`git init` + commit initial~~ → **fait (2026-08-25)** : `master`, commit `581f7e1`, poussé sur Gitea (`gitea-lab:nicolas/seiche`) - [x] E3. ~~Update §8 + statut final~~ → **fait (2026-08-25)** : §8 coché, §7/§9/§10/§11 mis à jour - [x] E4. Proxy (Caddy/nginx devant) : `SEICHE_TRUST_PROXY` + `SEICHE_COOKIE_SECURE` + 4 tests → **fait (2026-08-26)** diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..6da0308 --- /dev/null +++ b/server/README.md @@ -0,0 +1,51 @@ +# seiche-server + +Backend Fastify 5 (TypeScript strict, Node 22 via `tsx`). Sert l'API `/api/*` et, en production, le front prébuildé (`SEICHE_PUBLIC_DIR`) sur le même port — zéro CORS, 1 conteneur. + +## Scripts + +| Script | Rôle | +|---|---| +| `pnpm dev` | Serveur en watch sur `http://localhost:8787` (défauts config) | +| `pnpm start` | Serveur en mode prod (idem, sans watch) | +| `pnpm setup` | Crée le compte admin initial (idempotent ; `SEICHE_ADMIN_USERNAME` / `SEICHE_ADMIN_PASSWORD`, mdp ≥ 12 car.) | +| `pnpm db:generate` | `drizzle-kit generate` — génère les migrations dans `drizzle/` | +| `pnpm test` | Vitest (API injectée, SQLite `:memory:`) — 30 tests | +| `pnpm typecheck` | `tsc --noEmit` | + +Les migrations sont **appliquées automatiquement au boot** (`migrate()` de Drizzle, dossier `drizzle/`). + +## Structure + +``` +src/ +├── main.ts # boot : dataDir, DB + migrations, listen, shutdown propre +├── app.ts # buildApp (trustProxy, logger, plugins, routes) +├── config.ts # toutes les variables d'env (voir .env.example racine) +├── validation.ts # schémas Zod partagés +├── setup.ts # création idempotente du compte admin +├── auth/ # password (argon2id), session + CSRF, rate-limit +├── routes/ # auth, external, health +├── db/ # client libsql (WAL) + schema Drizzle +├── external/ # client du service REST externe (adaptateurs example/http) +└── drizzle/ # migrations versionnées (drizzle/) +``` + +## API + +| Route | Accès | Rôle | +|---|---|---| +| `GET /api/health` | public | healthcheck | +| `POST /api/auth/login` | rate-limit | session httpOnly + cookie CSRF (double-submit) | +| `POST /api/auth/logout` · `GET /api/auth/session` | session | révocation / état de la session | +| `GET /api/external/health` · `GET /api/external/items` · `POST /api/external/items` | session | pont vers le service externe (adaptateurs `example` / `http`) | + +Erreurs : corps JSON `{ statusCode, error, message, code, ... }` (le code machine est stable, le message ne fuit jamais les détails internes). + +## Tests + +```bash +pnpm test +``` + +Les tests injectent Fastify (`app.inject`) avec une base `:memory:` par worker (`tests/helpers.ts` : `makeConfig`, `openTestDb`, cookies) — pas de dépendance au réseau. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..0482ca4 --- /dev/null +++ b/web/README.md @@ -0,0 +1,33 @@ +# seiche-web + +Front Vite 7 + React 19 + TypeScript strict + Tailwind 4 + React Router 7 + TanStack Query 5. Interface en français. + +## Scripts + +| Script | Rôle | +|---|---| +| `pnpm dev` | Vite sur `http://localhost:5173`, proxy `/api` → `http://localhost:8787` (le serveur doit tourner) | +| `pnpm build` | `tsc --noEmit` + build de production dans `dist/` (servi par Fastify en prod) | +| `pnpm preview` | Prévisualisation du build | +| `pnpm test` | Vitest + jsdom + Testing Library | +| `pnpm typecheck` | `tsc --noEmit` | + +## Structure + +``` +src/ +├── main.tsx # QueryClient (staleTime 30 s, pas de retry 4xx) +├── App.tsx # routes : /login public, / sous RequireAuth, catch-all +├── lib/ +│ ├── api.ts # client API (fetch, credentials same-origin, ApiError) +│ ├── session.ts # useSession() — cache partagé login → guard → dashboard +│ └── queryClient.ts # QueryClient factorisé (partagé main ↔ tests) +├── components/RequireAuth.tsx # guard : spinner en attente, redirect /login si erreur +└── pages/ + ├── LoginPage.tsx # formulaire, erreurs mappées (401/429/500) + └── DashboardPage.tsx # user, expiry, logout +``` + +## Contrat API + +Le client (`lib/api.ts`) est aligné sur `server/src/routes/auth.ts` : codes `invalid_body` · `invalid_credentials` · `too_many_attempts` (avec `retryAfterMs`) · `not_authenticated`. En dev, le proxy Vite rend les appels relatifs (`/api/...`) isomorphes avec la prod (même origine).