docs: READMEs racine + server + web (E1) — dev, prod, env, backup, sécurité
This commit is contained in:
@@ -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
|
||||
```
|
||||
+6
-5
@@ -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)**
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user