# seoinsight v2 — Workflow de dev, migrations et authentification de l'API

Date : 2026-08-27
Statut : validé en brainstorming, en attente de relecture avant plan.
Amende : `2026-08-26-seoinsight-v2-architecture-design.md` (§3 propriété du schéma confirmée ; §4 dev local ; §6 auth ; §11 clés API).

## 1. Décisions

| Sujet | Décision | Alternative écartée et pourquoi |
|---|---|---|
| Postgres local | **Homebrew `postgresql@18`** (18.6), une instance, bases `auth`, `data`, `data_test`, rôle `seoinsight`. `compose.yaml` retiré. Prod alignée sur Postgres **18**. | Docker : plus lourd, conflit de port avec brew, aucun bénéfice ici. |
| Maître du schéma | **Inchangé** : Drizzle (+ CLI Better Auth) pour AUTH, SQLAlchemy + Alembic pour DATA. Une seule définition par table, dans l'app qui la lit et l'écrit. | Drizzle maître des deux bases : obligerait FastAPI à dupliquer les modèles en Python (dérive silencieuse) ou à les réfléchir (perte de typage), et éloignerait le schéma DATA de son seul utilisateur. |
| Lien Next → FastAPI | **JWT Better Auth EdDSA inchangé** (voie 1). | Header + secret partagé : secret permanent à protéger, FastAPI à isoler réseau, aucun client tiers possible, pour ~40 lignes de Python économisées. |
| Clients machines | **Clés API** hachées + scopes dans DATA (voie 2), spécifiées ici, implémentées au premier client (MCP ou CLI). | Faire passer les machines par Next (Next = proxy) : contredit « Next le plus léger possible ». |
| Isolation des features | **Une base de dev + reset + seed**, pas de base par feature. | Base par feature : rôles, `.env` à basculer, données à recréer — trop lourd pour l'équipe actuelle ; possible à la main si deux features divergent un jour. |

## 2. Postgres local (brew)

```bash
brew services start postgresql@18          # socket /tmp/.s.PGSQL.5432, port 5432
psql -h /tmp -d postgres -c "CREATE ROLE seoinsight LOGIN PASSWORD 'seoinsight' CREATEDB;"
createdb -h /tmp -O seoinsight auth
createdb -h /tmp -O seoinsight data
createdb -h /tmp -O seoinsight data_test
```

Ces commandes vivent dans un script idempotent `scripts/db-init.sh` à la racine (le seul script du repo ; tout autre utilitaire va dans le dépôt de scripts centralisé). Les `.env.example` restent `postgres://seoinsight:seoinsight@localhost:5432/auth` et `postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data` — rien ne change pour les apps.

`advmfr_hr` (autre projet) cohabite sur la même instance ; on ne la touche pas. Sauvegardes : `/Volumes/Stockage M4/Backup/postgresql/` (voir mémoire `postgres-brew-backups`).

## 3. Workflow de migration

### Pendant une feature (branche `feature/*`)

- **AUTH** : `bun run db:push` (`drizzle-kit push`) applique le schéma à la volée sur `auth`. Aucun fichier de migration créé.
- **DATA** : on modifie les modèles SQLAlchemy ; `uv run alembic revision --autogenerate -m wip` puis `uv run alembic upgrade head` pour itérer. Ces révisions `wip` sont **jetables** : `alembic downgrade <base>` + suppression des fichiers avant le squash final. Pas d'équivalent de `push` en SQLAlchemy — l'autogénération jetable joue ce rôle.

### En fin de feature, avant le merge dans `dev`

1. **AUTH** : `bun run db:generate` → une seule migration dans `web/drizzle/` ; `bun run db:reset` (drop + `db:migrate` + `db:seed`) prouve que la chaîne rejoue de zéro.
2. **DATA** : remettre `data` à l'état de `dev` (`uv run alembic downgrade <head de dev>`, suppression des `wip`), puis **une** révision nommée : `uv run alembic revision --autogenerate -m "sites"` ; `uv run alembic upgrade head` ; `uv run pytest` rejoue toute la chaîne sur `data_test`.
3. **Règles** : une feature = au plus une migration par base ; jamais de migration commitée sans son modèle ; jamais de `push` ni d'autogénération en CI/prod ; la review vérifie que le SQL généré correspond au modèle (pas de `DROP` accidentel, index et partitions explicites — spec architecture §8).

### Données de dev

- `web` : `bun run db:seed` crée l'utilisateur `dev@seoinsight.local` / `dev-password!` avec une org « Dev ».
- `api` : `uv run python -m app.seed` crée un site de démonstration pour cette org (quand le module Sites existera).
- `bun run db:reset` et `uv run python -m app.db_reset` enchaînent drop → migrate → seed. Ce sont les seules commandes destructrices ; elles refusent de tourner si `DATABASE_URL` ne pointe pas sur `localhost`.

## 4. Authentification de l'API — deux voies

```
                     FastAPI
          ┌────────────┴────────────┐
   Voie 1 : utilisateurs      Voie 2 : machines
   Next.js → JWT EdDSA        MCP / CLI / tiers → X-API-Key
   (livrée, stateless)        (à implémenter au 1er client)
          └────────────┬────────────┘
              Principal (dépendance unifiée)
```

### Voie 1 — inchangée

`Authorization: Bearer <JWT>` ; vérification locale de la signature via la JWKS de `web/` (cache), `iss`/`aud`/`exp`/`sub` exigés ; `org_id` émis par Next seulement si l'adhésion est vérifiée. Aucun secret sur la Dedibox. Code : `api/app/core/auth.py`.

### Voie 2 — clés API (spécifiée, non implémentée)

- **Format** : `sk_live_<key_id>_<secret>` — `key_id` = 12 caractères aléatoires (recherche indexée), `secret` = 32 octets aléatoires en base64url. Affichée **une seule fois** à la création.
- **Table `api_keys` (DATA, Alembic)** : `id`, `key_id` (unique), `secret_hash` (SHA-256 du secret ; suffisant car entropie élevée), `org_id`, `created_by` (user_id opaque), `name`, `scopes` (`text[]`), `last_used_at`, `expires_at` (nullable), `revoked_at` (nullable), `created_at`.
- **Vérification** : `X-API-Key` → split → lookup par `key_id` (cache mémoire in-process, TTL 60 s ; pas de Redis tant qu'il n'y a qu'un process) → comparaison constante du hash → `revoked_at`/`expires_at` → `Principal(kind="api_key", org_id, api_key_id, scopes)` ; `last_used_at` mis à jour au plus une fois par minute.
- **Gestion** : `POST /api-keys` (JWT utilisateur requis, rôle owner/admin de l'org), `GET /api-keys`, `DELETE /api-keys/{id}`. UI dans `web/` sous `/settings/api-keys`. Quotas et rate-limit par clé : plus tard, sur mesure.
- **Scopes** initiaux : `sites:read`, `sites:write`, `keywords:read`, `keywords:write`, `gsc:read`, `pages:read`. Les utilisateurs JWT ont implicitement tous les scopes de leur rôle d'org.

### Dépendance unifiée

```python
@dataclass(frozen=True)
class Principal:
    kind: Literal["user", "api_key"]
    org_id: str            # jamais None après CurrentOrgDep
    user_id: str | None
    api_key_id: str | None
    scopes: frozenset[str]

CurrentPrincipalDep  # Bearer → voie 1 ; sinon X-API-Key → voie 2 ; sinon 401 + WWW-Authenticate
CurrentOrgDep        # Principal avec org_id non nul, sinon 404 Problem Details (spec §9)
require_scope(name)  # 403 Problem Details si absent (voie 2 seulement)
```

`CurrentUser` actuel est renommé/absorbé dans `Principal` lors de l'implémentation de la voie 2 ; d'ici là, `CurrentOrgDep` est ajouté sur `CurrentUser` (SEOINSIGHT-15).

## 5. Erreurs, tests, documentation

- Erreurs : Problem Details inchangés ; 401 (`WWW-Authenticate: Bearer` ou `X-API-Key`), 403 scope manquant, 404 org absente.
- Tests : `api/tests` couvre les deux voies avec des clés générées en test ; `db-init.sh` est testé par la CI (à créer, SEOINSIGHT-34) qui démarre Postgres 18.
- Docs : `CLAUDE.md` (commandes brew, règles de migration), `README.md` (dev local), `docs/pre-prod-checklist.md` (rotation des clés API, TLS DB). Les SEOINSIGHT-3 (`/api/auth/token`) et SEOINSIGHT-15/16 sont résolues par cette spec.

## 6. Mise en œuvre (plan à écrire)

1. Bascule dev sur brew : `scripts/db-init.sh`, suppression de `compose.yaml` + `docker/`, docs.
2. Scripts `db:push` / `db:reset` / `db:seed` côté `web`, `app.db_reset` / `app.seed` côté `api` (garde-fou `localhost`).
3. `CurrentOrgDep` + import des modèles dans Alembic (préparation Sites).
4. Voie 2 : reportée au premier client machine (issue dédiée dans Plane).
