# Workflow de dev (Postgres brew), scripts de reset/seed et préparation Sites — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal :** le dev local tourne sur le Postgres Homebrew `postgresql@18` (plus de Docker), préparé par un seul script idempotent (`mise run db-init`) ; chaque app a ses commandes de migration à la volée, de remise à zéro et de seed (`bun run db:push` / `db:reset` / `db:seed` côté `web/`, `uv run python -m app.db_reset --org-id <id>` / `app.seed --org-id <id>` côté `api/`), toutes protégées par un garde-fou `localhost` testé ; `api/` expose `CurrentOrgDep` (404 Problem Details sans organisation) et enregistre ses modèles pour l'autogénération Alembic, avec les règles de migration documentées dans `CLAUDE.md`.

**Architecture :** inchangée (spec 2026-08-26) : `web/` possède la base AUTH via Drizzle + Better Auth, `api/` possède la base DATA via SQLAlchemy + Alembic, lien unique par JWT EdDSA. Rien n'est partagé entre les bases : l'`org_id` de l'organisation « Dev » est imprimé par le seed web (`ORG_ID=<id>`) et passé explicitement au seed api. Ce plan ne touche qu'à l'outillage de dev et à deux préparations côté `api/`. La voie 2 « clés API » (spec 2026-08-27 §4) est **hors périmètre** : reportée au premier client machine.

**Tech Stack :** Postgres **18.6 Homebrew** (`postgresql@18`, socket `/tmp/.s.PGSQL.5432`, port 5432, superuser `humperdoo`, `pg_hba` en `trust` local) · mise (tâche `db-init`) · bun 1.4, Next 16.2, Better Auth 1.7.1, Drizzle 0.45 + drizzle-kit 0.31 + postgres.js, T3 Env + zod 4, oxlint/oxfmt, vitest 4 · uv, Python 3.13, FastAPI 0.141, SQLAlchemy 2 async + asyncpg, Alembic 1.19, pydantic-settings, PyJWT, pytest 9 + pytest-asyncio + httpx, ruff.

**Spec :** `docs/superpowers/specs/2026-08-27-workflow-dev-et-auth-api-design.md` (§2, §3, §5, §6 items 1–3), contexte dans `docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md`.

## Global Constraints

- **CLIs officiels uniquement** pour toute ossature : `bunx --bun shadcn@latest add …`, `bunx auth@latest generate`, `bunx drizzle-kit …`, `uv init`, `uv add`, `uvx alembic init -t async`. Jamais de squelette écrit à la main.
- **Toute nouvelle dépendance JS** : `sfw bun add <pkg>` (ou `sfw bun add -d`). Python : `uv add` / `uv add --dev`. Ce plan n'ajoute **aucune** dépendance.
- **`web/` ne se connecte qu'à la base `auth`**, **`api/` qu'à la base `data`** (`data_test` pour les tests).
- **Appels `web/` → `api/` côté serveur uniquement** (Server Components / Server Actions). Jamais depuis le navigateur.
- **JWT** : EdDSA (défaut Better Auth), `expirationTime: "15m"`, payload = `sub` (user id), `email`, `org_id` (organisation active, peut être `null`).
- **Next 16** : `proxy.ts` (pas `middleware.ts`), `headers()`/`cookies()` sont `await`, `error.tsx` en `'use client'`, chaque segment de route significatif a `loading.tsx` + `error.tsx` + `not-found.tsx`. Server Components par défaut, `'use client'` aux feuilles. Mutations = Server Actions + validation Zod côté serveur.
- **`experimental.optimizePackageImports`** dans `next.config.ts` pour tout package dont on n'importe que quelques exports (`lucide-react` est déjà optimisé par défaut — ne pas l'ajouter).
- **Env** : T3 Env (web) / pydantic-settings (api). Aucune valeur par défaut silencieuse ; l'app échoue au démarrage si une variable manque. Les `.env.example` restent `postgres://seoinsight:seoinsight@localhost:5432/auth` et `postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data` — **inchangés**.
- **Erreurs API** : Problem Details RFC 9457, `Content-Type: application/problem+json`.
- **Lint** : `bun run lint` (`oxlint --deny-warnings .`) et `bun run format:check` (oxfmt) verts avant chaque commit web ; `uvx ruff check .` et `uvx ruff format --check .` verts avant chaque commit api. Tests : `bun run test` (vitest) et `uv run pytest -q -W error`.
- **Ports de dev** : web `3100`, api `8000`.
- **Un seul script dans le repo** : `scripts/db-init.sh` (admis par la spec §2), exposé par la tâche mise `db-init`. Tout autre utilitaire va dans le dépôt de scripts centralisé. Les commandes `db:*` et `app.db_reset` / `app.seed` sont du code d'application (fichiers `.ts` / `.py` lancés par bun / uv), pas des scripts shell.
- **Git** : branche `feature/<nom>` depuis `dev`, Conventional Commits, merge `--ff-only` dans `dev`, jamais de commit sur `main`. Un hook lit le message de commit dans la commande shell : **jamais de heredoc dans la même commande qu'un `git commit`** (message sur une ligne avec `-m`).
- **Sandbox** : les agents ne peuvent joindre Postgres ni écrire dans `.claude/skills` / `.mcp.json`. Toute étape marquée **« (utilisateur) »** exige Postgres et doit être lancée par l'exécutant hors sandbox ou par l'utilisateur (`! …`) ; l'agent écrit le code, lance lint/format, et attend le résultat de ces étapes avant de committer.
- Toute dette ou valeur temporaire est notée immédiatement dans `docs/pre-prod-checklist.md`.

---

## Carte des fichiers

### Racine

| Fichier | Responsabilité |
|---|---|
| `scripts/db-init.sh` | **Créé.** Rôle `seoinsight` (LOGIN, CREATEDB) et bases `auth`, `data`, `data_test` sur le Postgres brew. Idempotent. Seul script du repo |
| `compose.yaml`, `docker/postgres-init/01-databases.sql` | **Supprimés** (Postgres Docker abandonné) |
| `mise.toml` | Section `[tasks]` : `db-init = "scripts/db-init.sh"` (`mise run db-init`) |
| `.gitignore` | Retrait de `pgdata/` (volume Docker) et de `web/.env.example` (fichier suivi, qui doit le rester) |
| `README.md` | Section Dev : brew + `mise run db-init` + commandes de reset/seed |
| `CLAUDE.md` | Structure et Commandes (brew, mise), nouvelle section « Migrations et données de dev » (spec §3) |
| `docs/pre-prod-checklist.md` | Postgres 18 en prod, TLS de la base DATA |
| `docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md` | §4, §10, §12 : Docker → brew (mise en cohérence avec l'amendement du 2026-08-27) |

### `web/`

| Fichier | Responsabilité |
|---|---|
| `lib/db/guard.ts` | `assertLocalDatabaseUrl(url)` : refuse tout hôte hors `localhost` / `127.0.0.1` |
| `lib/db/guard.test.ts` | Tests vitest du garde-fou |
| `lib/db/reset.ts` | Vide la base `auth` (schémas `public` et `drizzle`) après garde-fou |
| `lib/db/seed.ts` | Utilisateur `dev@seoinsight.local` / `dev-password!` + organisation « Dev » via l'API serveur Better Auth ; idempotent ; imprime `ORG_ID=<id>` |
| `package.json` | Scripts `db:push`, `db:seed`, `db:reset` |
| `.vercelignore` | Exclusion de `lib/db/reset.ts` et `lib/db/seed.ts` |

### `api/`

| Fichier | Responsabilité |
|---|---|
| `app/db_reset.py` | `assert_local(url)`, `drop_public_schema(url)`, `parse_args(argv)`, `main(argv)` : drop → `alembic upgrade head` → `seed.run(org_id)` ; `--org-id` obligatoire |
| `app/seed.py` | `run(org_id)` no-op documenté tant que Sites n'existe pas ; `--org-id` obligatoire (argparse) |
| `app/db/models.py` | Registre des modèles : `Base` + un import par module métier (vide pour l'instant) |
| `app/core/auth.py` | `CurrentOrgUser`, `get_current_org_user`, `CurrentOrgDep` (404 Problem Details si `org_id` absent) |
| `alembic/env.py` | Importe `Base` depuis `app.db.models` |
| `tests/conftest.py` | Route de test `/_test/org` |
| `tests/test_db_reset.py`, `tests/test_seed.py`, `tests/test_org.py`, `tests/test_migrations.py` | Tests du garde-fou et des arguments, du seed, de `CurrentOrgDep`, de la cohérence modèles ↔ migrations |

---

### Task 1 : Bascule du Postgres de dev sur Homebrew

**Files:**
- Create: `scripts/db-init.sh`
- Delete: `compose.yaml`, `docker/postgres-init/01-databases.sql` (et le dossier `docker/`)
- Modify: `mise.toml`, `.gitignore`, `README.md`, `CLAUDE.md`, `docs/pre-prod-checklist.md`, `docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md`

**Interfaces:**
- Produces : commande `mise run db-init` (= `scripts/db-init.sh`, idempotente) ; les URLs des `.env.example` restent valides telles quelles ; `brew services start postgresql@18` comme unique prérequis.

- [ ] **Step 1 : Créer la branche**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git checkout dev && git pull && git checkout -b feature/dev-postgres-brew
```

- [ ] **Step 2 : Écrire `scripts/db-init.sh`**

```bash
#!/usr/bin/env bash
# Prépare le Postgres Homebrew (postgresql@18) pour seoinsight : rôle `seoinsight` (LOGIN,
# CREATEDB) et bases `auth`, `data`, `data_test` dont il est propriétaire.
# Idempotent : relançable à volonté. Ne touche à rien d'autre (la base `advmfr_hr` d'un
# autre projet cohabite sur la même instance).
#
# Prérequis : brew services start postgresql@18
# Usage     : mise run db-init   (ou scripts/db-init.sh, depuis n'importe quel répertoire)
# Variables : PGHOST (défaut /tmp = socket brew), PGPORT (défaut 5432),
#             PGSUPERUSER (défaut : utilisateur courant, superuser de l'instance brew)
set -euo pipefail

if [ -d /opt/homebrew/opt/postgresql@18/bin ]; then
  export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"
fi

PGHOST="${PGHOST:-/tmp}"
PGPORT="${PGPORT:-5432}"
PGSUPERUSER="${PGSUPERUSER:-$(id -un)}"
ROLE="seoinsight"
ROLE_PASSWORD="seoinsight"
DATABASES=(auth data data_test)

if ! pg_isready -h "$PGHOST" -p "$PGPORT" -q; then
  echo "Postgres injoignable sur $PGHOST:$PGPORT. Lancer : brew services start postgresql@18" >&2
  exit 1
fi

admin() {
  psql -X -q -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGSUPERUSER" -d postgres "$@"
}

if [ "$(admin -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$ROLE'")" = "1" ]; then
  echo "rôle $ROLE : déjà présent"
else
  admin -c "CREATE ROLE $ROLE LOGIN PASSWORD '$ROLE_PASSWORD' CREATEDB"
  echo "rôle $ROLE : créé"
fi

for db in "${DATABASES[@]}"; do
  if [ "$(admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then
    echo "base $db : déjà présente"
  else
    createdb -h "$PGHOST" -p "$PGPORT" -U "$PGSUPERUSER" -O "$ROLE" "$db"
    echo "base $db : créée (propriétaire $ROLE)"
  fi
done

# Vérification finale par le chemin qu'utilisent les apps (TCP localhost, rôle seoinsight).
for db in "${DATABASES[@]}"; do
  psql -X -q -v ON_ERROR_STOP=1 -h localhost -p "$PGPORT" -U "$ROLE" -d "$db" -c "SELECT 1" >/dev/null
done
echo "OK : $ROLE se connecte à ${DATABASES[*]} sur localhost:$PGPORT"
```

Puis `chmod +x scripts/db-init.sh && bash -n scripts/db-init.sh` — Expected : aucune sortie (syntaxe valide). `shellcheck` n'est pas installé sur la machine ; ne pas l'ajouter.

- [ ] **Step 3 : Supprimer Docker, nettoyer `.gitignore`, déclarer la tâche mise**

```bash
git rm -q compose.yaml docker/postgres-init/01-databases.sql && rm -rf docker && ls docker 2>&1
```

Expected : `ls: docker: No such file or directory`.

Dans `.gitignore`, supprimer **deux** lignes du bloc `# Données locales` :

- `pgdata/` — volume Docker, plus rien ne le crée ;
- `web/.env.example` — le fichier est suivi par git et doit le rester (le README le copie en `.env`) ; l'ignore était sans effet mais trompeur.

Le bloc devient :

```
# Données locales
.mcp.json
*.secrets.env
.claude/skills/
```

Remplacer `mise.toml` par :

```toml
# Runtimes épinglés pour le repo : `mise install` les installe.
# Postgres n'est pas géré par mise : Homebrew `postgresql@18` (`brew services start postgresql@18`).
[tools]
bun = "1.4"
python = "3.13"
uv = "latest"

# `mise run db-init` : rôle seoinsight + bases auth, data, data_test (idempotent).
[tasks]
db-init = "scripts/db-init.sh"
```

Vérifier : `mise tasks` — Expected : une ligne `db-init`.

- [ ] **Step 4 : `README.md` — remplacer la section `## Dev` en entier** (tout ce qui suit le titre `## Dev`, jusqu'à la fin du fichier) par :

````markdown
## Dev

Postgres de dev : Homebrew `postgresql@18` (une instance, bases `auth`, `data`, `data_test`,
rôle `seoinsight`). Démarrer le service, puis créer rôle et bases (tâche mise idempotente,
équivalente à `scripts/db-init.sh`) :

```bash
brew services start postgresql@18
mise run db-init
```

Variables d'environnement — copier les gabarits puis remplir les secrets
(`BETTER_AUTH_SECRET` : `bunx auth@latest secret`, `RESEND_API_KEY`) :

```bash
cp web/.env.example web/.env
cp api/.env.example api/.env
```

`AUTH_BASE_URL` (api) doit valoir exactement `BETTER_AUTH_URL` (web) : c'est à la fois
l'`iss` et l'`aud` des jetons.

Migrations — une par base, à rejouer après chaque `git pull` :

```bash
cd web && bun install && bun run db:migrate          # base auth (Drizzle)
cd ../api && uv sync && uv run alembic upgrade head  # base data (Alembic)
```

Données de dev et remise à zéro (dev uniquement : refus si `DATABASE_URL` n'est pas `localhost`).
Rien n'est partagé entre les bases : `bun run db:seed` imprime `ORG_ID=<id>` (organisation
« Dev »), à passer explicitement aux commandes de l'api :

```bash
cd web && bun run db:seed                           # dev@seoinsight.local / dev-password! + org « Dev » → ORG_ID=<id>
cd web && bun run db:reset                          # drop → db:migrate → db:seed
cd api && uv run python -m app.seed --org-id <id>   # no-op tant que le module Sites n'existe pas
cd api && uv run python -m app.db_reset --org-id <id>  # drop → alembic upgrade head → seed
```

Lancer les deux apps (un terminal chacune) :

```bash
cd web && d3k                  # http://localhost:3100 (dev3000, pas `next dev` directement)
cd api && uv run fastapi dev   # http://localhost:8000
```

L'api se construit aussi en image Docker (`api/Dockerfile`, multi-stage uv, non-root) :

```bash
cd api && docker build -t seoinsight-api .
```
````

(Les commandes `db:seed` / `db:reset` / `app.seed` / `app.db_reset` sont livrées par les Tasks 2 et 3 ; le README les documente dès maintenant pour ne pas être réécrit trois fois.)

- [ ] **Step 5 : `CLAUDE.md` — Structure et Commandes**

Dans la section `## Structure`, remplacer la ligne :

```
- `compose.yaml` — Postgres de dev (bases `auth`, `data`, `data_test`).
```

par :

```
- Postgres de dev : Homebrew `postgresql@18` (bases `auth`, `data`, `data_test`, rôle `seoinsight`), préparé par `mise run db-init` → `scripts/db-init.sh`, le **seul** script du repo.
```

Remplacer le bloc `## Commandes` en entier par :

````markdown
## Commandes

```bash
brew services start postgresql@18   # Postgres 18 (socket /tmp/.s.PGSQL.5432, port 5432)
mise run db-init                    # rôle seoinsight + bases auth, data, data_test (idempotent)
cd web && d3k                       # Next (bun), port 3100
cd api && uv run fastapi dev        # FastAPI, port 8000
```
````

- [ ] **Step 6 : Amender la spec d'architecture et la checklist**

Dans `docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md`, quatre remplacements ligne à ligne (texte exact) :

§4, arbre du repo — ligne actuelle :

```
├── compose.yaml                # dev : 1 service Postgres, 2 bases (auth, data)
```

nouvelle ligne :

```
├── scripts/db-init.sh          # dev : rôle et bases sur le Postgres Homebrew (seul script du repo, `mise run db-init`)
```

§4, dev local — ligne actuelle :

```
Dev local : `docker compose up -d`, puis `d3k` dans `web/` et `uv run fastapi dev` dans `api/`.
```

nouvelle ligne :

```
Dev local : `brew services start postgresql@18` puis `mise run db-init` (une fois), ensuite `d3k` dans `web/` et `uv run fastapi dev` dans `api/` (amendé par la spec 2026-08-27 §2 : Postgres Homebrew, plus de Docker).
```

§10, tests — ligne actuelle :

```
- `api/` : pytest contre une base `data_test` du même Postgres Docker (migrations Alembic appliquées au setup). Tests d'intégration des routers via `httpx.AsyncClient`, JWT signé par une clé de test. C'est là que vit l'essentiel de la logique, donc l'essentiel des tests.
```

nouvelle ligne :

```
- `api/` : pytest contre une base `data_test` du même Postgres Homebrew (migrations Alembic appliquées au setup). Tests d'intégration des routers via `httpx.AsyncClient`, JWT signé par une clé de test. C'est là que vit l'essentiel de la logique, donc l'essentiel des tests.
```

§12, phase 0 — ligne actuelle :

```
0. **Squelette du repo** : `git init`, remote, `compose.yaml`, `CLAUDE.md`, `README.md`, `.gitignore`.
```

nouvelle ligne :

```
0. **Squelette du repo** : `git init`, remote, `scripts/db-init.sh` + `mise.toml`, `CLAUDE.md`, `README.md`, `.gitignore`.
```

Vérifier : `grep -n "docker compose\|compose.yaml\|Postgres Docker" docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md` — Expected : aucune ligne.

Ajouter à la fin du tableau de `docs/pre-prod-checklist.md` :

```markdown
| 2026-08-28 | Prod alignée sur Postgres **18** (dev = Homebrew `postgresql@18` 18.6) | Dedibox | Installer Postgres 18 (pas 17) sur la Dedibox avant le premier déploiement ; `asyncpg` et `postgres.js` sont déjà compatibles |
| 2026-08-28 | Base DATA : TLS si `api/` et Postgres ne tournent pas sur la même machine | `api/.env` (`DATABASE_URL`) | Ajouter `?ssl=require` à l'URL asyncpg dès que la base sort du réseau local de la Dedibox (l'item AUTH/Vercel du 2026-08-26 couvre l'autre base) |
```

- [ ] **Step 7 : Vérification (utilisateur — exige Postgres)**

```bash
brew services start postgresql@18 && sleep 2 && pg_isready -h /tmp
mise run db-init
mise run db-init
psql -h localhost -U seoinsight -d postgres -tAc "SELECT datname FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = 'seoinsight') ORDER BY 1"
psql -h localhost -U seoinsight -d postgres -tAc "SELECT rolcreatedb FROM pg_roles WHERE rolname = 'seoinsight'"
psql -h /tmp -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'advmfr_hr'"
```

Expected : 1er `db-init` → `rôle seoinsight : créé`, trois `base … : créée`, `OK : …` ; 2e → `déjà présent` / `déjà présente` ×3, `OK`, code de sortie 0 ; la liste vaut `auth`, `data`, `data_test` ; `rolcreatedb` vaut `t` ; `advmfr_hr` renvoie `1` (intacte). Puis rejouer les migrations sur les bases neuves :

```bash
cd web && bun run db:migrate && cd ../api && uv run alembic upgrade head && uv run pytest -q -W error
```

Expected : `drizzle-kit migrate` termine sans erreur, `alembic upgrade head` affiche `Running upgrade  -> b86e39e2c13b, init`, pytest → `12 passed` (la base `data_test` reçoit `alembic_version`). Enfin `d3k` dans `web/`, créer un compte sur `http://localhost:3100/sign-up` — Expected : inscription OK, `d3k errors` vide.

- [ ] **Step 8 : Commit et merge**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A && git status --short
git commit -m "build: Postgres de dev sur Homebrew postgresql@18 via mise run db-init, retrait de Docker"
git checkout dev && git merge feature/dev-postgres-brew --ff-only && git push && git branch -d feature/dev-postgres-brew
```

---

### Task 2 : `web/` — garde-fou, `db:push`, `db:reset`, `db:seed`

**Files:**
- Create: `web/lib/db/guard.ts`, `web/lib/db/guard.test.ts`, `web/lib/db/reset.ts`, `web/lib/db/seed.ts`
- Modify: `web/package.json` (scripts), `web/.vercelignore`

**Interfaces:**
- Consumes : `env.DATABASE_URL` (`@/env`), `db` (`@/lib/db`), `auth.api.signUpEmail` / `auth.api.createOrganization` (`@/lib/auth`), tables `user` et `organization` (`@/lib/db/auth-schema`).
- Produces : `assertLocalDatabaseUrl(url: string): void` (lève `Error` si l'hôte n'est pas `localhost` / `127.0.0.1`) ; scripts `bun run db:push`, `bun run db:seed`, `bun run db:reset` ; constantes `DEV_USER` (`{ email: "dev@seoinsight.local", password: "dev-password!", name: "Dev" }`) et `DEV_ORG` (`{ name: "Dev", slug: "dev" }`) exportées par `lib/db/seed.ts` ; **dernière ligne de sortie de `db:seed` : `ORG_ID=<id>`**, consommée par `app.seed --org-id` / `app.db_reset --org-id` (Task 3).

**Choix du seed : API serveur Better Auth, pas d'INSERT Drizzle.** `auth.api.signUpEmail` hache le mot de passe avec l'algorithme de Better Auth et crée `user` + `account` (`providerId`, `issuer`, `accountId`) selon ses propres conventions ; `auth.api.createOrganization({ body: { name, slug, userId } })` accepte `userId` sans session (vérifié dans `better-auth@1.7.1`, `plugins/organization/routes/crud-org.mjs` : sans `headers`, `userId` est traité comme action système) et crée l'org **et** le membre `owner`. Un INSERT Drizzle devrait reproduire hachage, `issuer`, format d'id et rôle à la main — dérive garantie à la prochaine version de Better Auth. Les hooks de `lib/auth.ts` (`activeOrganizationId` posé à la création de session) s'appliquent tels quels.

- [ ] **Step 1 : Branche** — `git checkout -b feature/web-db-scripts dev`

- [ ] **Step 2 : Test du garde-fou (échoue d'abord)** — `web/lib/db/guard.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { assertLocalDatabaseUrl } from "./guard";

describe("assertLocalDatabaseUrl", () => {
  it("accepte localhost et 127.0.0.1", () => {
    expect(() =>
      assertLocalDatabaseUrl("postgres://seoinsight:seoinsight@localhost:5432/auth"),
    ).not.toThrow();
    expect(() =>
      assertLocalDatabaseUrl("postgres://seoinsight:seoinsight@127.0.0.1:5432/auth"),
    ).not.toThrow();
  });

  it("refuse tout autre hôte en le nommant", () => {
    expect(() =>
      assertLocalDatabaseUrl("postgres://seoinsight:secret@db.example.com:5432/auth"),
    ).toThrow(/db\.example\.com/);
    expect(() => assertLocalDatabaseUrl("postgres://seoinsight:secret@10.0.0.5:5432/auth")).toThrow();
    expect(() =>
      assertLocalDatabaseUrl("postgres://seoinsight:secret@localhost.example.com:5432/auth"),
    ).toThrow();
  });

  it("refuse une URL sans hôte", () => {
    expect(() => assertLocalDatabaseUrl("postgres:///auth")).toThrow();
  });
});
```

Run : `cd web && bun run test` — Expected : FAIL (`./guard` introuvable).

- [ ] **Step 3 : `web/lib/db/guard.ts`**

```ts
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]);

/**
 * Garde-fou des commandes destructrices (`db:reset`) et du seed : elles ne tournent que
 * contre un Postgres local. Lève une erreur nommant l'hôte refusé.
 */
export function assertLocalDatabaseUrl(url: string): void {
  const { hostname } = new URL(url);
  if (!LOCAL_HOSTS.has(hostname)) {
    throw new Error(
      `Refus : DATABASE_URL pointe sur « ${hostname || "(aucun hôte)"} », pas sur localhost`,
    );
  }
}
```

Run : `bun run test` — Expected : `8 passed` (5 existants + 3).

- [ ] **Step 4 : `web/lib/db/reset.ts`** (vide `public` **et** `drizzle` : drizzle-kit journalise les migrations dans `drizzle.__drizzle_migrations` ; sans ce drop, `db:migrate` croirait tout appliqué) :

```ts
import postgres from "postgres";
import { env } from "@/env";
import { assertLocalDatabaseUrl } from "./guard";

async function main(): Promise<void> {
  assertLocalDatabaseUrl(env.DATABASE_URL);
  const sql = postgres(env.DATABASE_URL, { max: 1 });
  try {
    await sql.unsafe("DROP SCHEMA IF EXISTS drizzle CASCADE");
    await sql.unsafe("DROP SCHEMA IF EXISTS public CASCADE");
    await sql.unsafe("CREATE SCHEMA public");
  } finally {
    await sql.end();
  }
  console.log("Base auth vidée (schémas public et drizzle recréés).");
}

main()
  .then(() => process.exit(0))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exit(1);
  });
```

- [ ] **Step 5 : `web/lib/db/seed.ts`**

```ts
import { eq } from "drizzle-orm";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { organization, user } from "@/lib/db/auth-schema";
import { assertLocalDatabaseUrl } from "./guard";

export const DEV_USER = {
  email: "dev@seoinsight.local",
  password: "dev-password!",
  name: "Dev",
} as const;

export const DEV_ORG = { name: "Dev", slug: "dev" } as const;

async function ensureUser(): Promise<string> {
  const existing = await db.query.user.findFirst({
    where: eq(user.email, DEV_USER.email),
    columns: { id: true },
  });
  if (existing) {
    console.log(`utilisateur ${DEV_USER.email} : déjà présent (${existing.id})`);
    return existing.id;
  }
  const created = await auth.api.signUpEmail({ body: { ...DEV_USER } });
  console.log(`utilisateur ${DEV_USER.email} : créé (${created.user.id})`);
  return created.user.id;
}

async function ensureOrg(userId: string): Promise<string> {
  const existing = await db.query.organization.findFirst({
    where: eq(organization.slug, DEV_ORG.slug),
    columns: { id: true },
  });
  if (existing) {
    console.log(`organisation « ${DEV_ORG.name} » : déjà présente (${existing.id})`);
    return existing.id;
  }
  // Sans `headers`, Better Auth traite l'appel comme une action système : `userId` devient
  // le créateur et reçoit le rôle `owner`.
  const created = await auth.api.createOrganization({ body: { ...DEV_ORG, userId } });
  if (!created) throw new Error("Better Auth n'a pas renvoyé l'organisation créée");
  console.log(`organisation « ${DEV_ORG.name} » : créée (${created.id})`);
  return created.id;
}

async function main(): Promise<void> {
  assertLocalDatabaseUrl(env.DATABASE_URL);
  const userId = await ensureUser();
  const orgId = await ensureOrg(userId);
  console.log(`Seed auth OK — user_id=${userId}`);
  // Dernière ligne, format stable : c'est l'identifiant à passer à
  // `uv run python -m app.seed --org-id <id>` et `app.db_reset --org-id <id>`.
  // Rien n'est partagé entre les bases AUTH et DATA.
  console.log(`ORG_ID=${orgId}`);
}

// `process.exit` : le pool postgres.js de `@/lib/db` garderait le process bun ouvert.
main()
  .then(() => process.exit(0))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exit(1);
  });
```

Si `bun run lint` refuse `created.id` (type de retour de `createOrganization` jugé nullable différemment), garder le test `if (!created)` tel quel — il est là pour ça — et ne pas ajouter de `!`.

- [ ] **Step 6 : Scripts et `.vercelignore`**

Dans `web/package.json` → `"scripts"`, après `"db:migrate"`, ajouter :

```json
"db:push": "drizzle-kit push",
"db:seed": "bun lib/db/seed.ts",
"db:reset": "bun lib/db/reset.ts && bun run db:migrate && bun run db:seed",
```

Remplacer `web/.vercelignore` par :

```
.claude
.agents
*.test.ts
vitest.config.ts
lib/db/reset.ts
lib/db/seed.ts
```

- [ ] **Step 7 : Lint, format, build** — `bun run lint && bun run format:check && bun run test && bun run build` — Expected : tout vert (`format:check` peut demander `bun run format` d'abord ; `build` type-checke `reset.ts` et `seed.ts` puisque `tsconfig` inclut `**/*.ts`).

- [ ] **Step 8 : Vérification (utilisateur — exige Postgres)**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/web
bun run db:reset
bun run db:seed | tail -n 1
psql -h localhost -U seoinsight -d auth -tAc 'SELECT email FROM "user"; SELECT name, slug FROM organization; SELECT role FROM member;'
DATABASE_URL=postgres://seoinsight:secret@db.example.com:5432/auth bun lib/db/reset.ts; echo "exit=$?"
bun run db:push
```

Expected : `db:reset` → `Base auth vidée …`, migration Drizzle appliquée, `utilisateur … : créé`, `organisation … : créée`, `Seed auth OK — user_id=…`, puis `ORG_ID=<id>` en dernière ligne ; 2e `db:seed` → `déjà présent` / `déjà présente`, et `tail -n 1` affiche le **même** `ORG_ID=<id>` ; psql → `dev@seoinsight.local`, `Dev|dev`, `owner` ; la commande avec l'URL distante affiche `Refus : DATABASE_URL pointe sur « db.example.com », pas sur localhost` et `exit=1` (bun ne laisse pas `.env` écraser une variable déjà définie dans le shell) ; `db:push` ne propose aucune instruction SQL (`No changes detected` ou équivalent). Noter l'`ORG_ID` : la Task 3 l'utilise. Puis sous `d3k` : connexion sur `http://localhost:3100/sign-in` avec `dev@seoinsight.local` / `dev-password!` — Expected : arrivée sur `/dashboard`, en-tête « Dev », `d3k errors` vide.

- [ ] **Step 9 : Commit et merge**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(web): scripts db:push, db:reset et db:seed avec garde-fou localhost"
git checkout dev && git merge feature/web-db-scripts --ff-only && git push && git branch -d feature/web-db-scripts
```

---

### Task 3 : `api/` — `app.db_reset` et `app.seed` avec garde-fou et `--org-id`

**Files:**
- Create: `api/app/db_reset.py`, `api/app/seed.py`, `api/tests/test_db_reset.py`, `api/tests/test_seed.py`

**Interfaces:**
- Consumes : `get_settings().database_url` (`app.core.config`), `alembic.ini` (cwd = `api/`), l'`ORG_ID` imprimé par `bun run db:seed` (Task 2).
- Produces, dans `app.seed` : `run(org_id: str) -> Awaitable[None]`, `parse_args(argv: list[str] | None) -> argparse.Namespace` (`--org-id` obligatoire), `main(argv: list[str] | None = None) -> None`. Dans `app.db_reset` : `assert_local(url: str) -> None` (lève `RuntimeError` si l'hôte n'est pas `localhost` / `127.0.0.1`), `drop_public_schema(url: str) -> Awaitable[None]`, `parse_args(argv)` (`--org-id` obligatoire, transmis au seed), `main(argv: list[str] | None = None) -> int` (code de sortie). Commandes : `uv run python -m app.db_reset --org-id <id>`, `uv run python -m app.seed --org-id <id>`.

- [ ] **Step 1 : Branche** — `git checkout -b feature/api-db-scripts dev`

- [ ] **Step 2 : Tests (échouent d'abord)** — `api/tests/test_db_reset.py` :

```python
from types import SimpleNamespace

import pytest

from app import db_reset

ORG_ID = "org_dev"


@pytest.mark.parametrize(
    "url",
    [
        "postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data",
        "postgresql+asyncpg://seoinsight:seoinsight@127.0.0.1:5432/data_test",
    ],
)
def test_assert_local_accepts_local_hosts(url: str) -> None:
    db_reset.assert_local(url)


@pytest.mark.parametrize(
    "url",
    [
        "postgresql+asyncpg://seoinsight:secret@db.example.com:5432/data",
        "postgresql+asyncpg://seoinsight:secret@10.0.0.5:5432/data",
        "postgresql+asyncpg://seoinsight:secret@localhost.example.com:5432/data",
    ],
)
def test_assert_local_rejects_remote_hosts(url: str) -> None:
    with pytest.raises(RuntimeError, match="Refus"):
        db_reset.assert_local(url)


def test_main_requires_org_id() -> None:
    # argparse : argument obligatoire absent → usage + code de sortie 2.
    with pytest.raises(SystemExit) as exc:
        db_reset.main([])
    assert exc.value.code == 2


def test_main_refuses_before_touching_database(
    monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    remote = "postgresql+asyncpg://seoinsight:secret@db.example.com:5432/data"
    monkeypatch.setattr(db_reset, "get_settings", lambda: SimpleNamespace(database_url=remote))

    async def _must_not_run(_url: str) -> None:
        raise AssertionError("drop_public_schema ne doit pas être appelé")

    monkeypatch.setattr(db_reset, "drop_public_schema", _must_not_run)

    assert db_reset.main(["--org-id", ORG_ID]) == 1
    assert "db.example.com" in capsys.readouterr().err
```

`api/tests/test_seed.py` :

```python
import pytest

from app import seed


def test_seed_requires_org_id() -> None:
    with pytest.raises(SystemExit) as exc:
        seed.main([])
    assert exc.value.code == 2


async def test_seed_run_names_the_org(capsys: pytest.CaptureFixture[str]) -> None:
    await seed.run("org_dev")
    assert "org_dev" in capsys.readouterr().out
```

Run (utilisateur, Postgres démarré — toute la suite dépend de `data_test`) : `cd api && uv run pytest -q -W error tests/test_db_reset.py tests/test_seed.py` — Expected : `ImportError` sur `app.db_reset` et `app.seed`.

- [ ] **Step 3 : `api/app/seed.py`**

```python
"""Données de démonstration de la base DATA.

No-op tant que le module Sites n'existe pas. Quand il existera, `run(org_id)` créera un site
de démonstration rattaché à l'organisation « Dev » de la base AUTH. Rien n'est partagé entre
les bases : `bun run db:seed` (web/) imprime `ORG_ID=<id>`, et cet identifiant est passé ici
explicitement.

Usage : uv run python -m app.seed --org-id <id>
"""

import argparse
import asyncio


async def run(org_id: str) -> None:
    print(f"seed DATA : rien à créer pour l'organisation {org_id} (le module Sites n'existe pas encore)")


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="python -m app.seed", description="Données de démonstration de la base DATA."
    )
    parser.add_argument(
        "--org-id",
        required=True,
        help="identifiant de l'organisation « Dev » (ligne ORG_ID=… de `bun run db:seed`)",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> None:
    asyncio.run(run(parse_args(argv).org_id))


if __name__ == "__main__":
    main()
```

- [ ] **Step 4 : `api/app/db_reset.py`**

```python
"""Remise à zéro de la base DATA de dev : drop du schéma public → alembic upgrade head → seed.

Commande destructrice, réservée au dev : refuse toute DATABASE_URL dont l'hôte n'est pas
localhost / 127.0.0.1.

Usage : uv run python -m app.db_reset --org-id <id>   (depuis api/, où se trouve alembic.ini)
"""

import argparse
import asyncio
import sys
from urllib.parse import urlsplit

from alembic import command
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine

from app import seed
from app.core.config import get_settings

LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1"})


def assert_local(url: str) -> None:
    host = urlsplit(url).hostname
    if host not in LOCAL_HOSTS:
        raise RuntimeError(f"Refus : DATABASE_URL pointe sur {host!r}, pas sur localhost")


async def drop_public_schema(url: str) -> None:
    # Le rôle seoinsight est propriétaire de la base : il peut recréer `public`.
    engine = create_async_engine(url)
    try:
        async with engine.begin() as conn:
            await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
            await conn.execute(text("CREATE SCHEMA public"))
    finally:
        await engine.dispose()


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="python -m app.db_reset",
        description="Vide la base DATA de dev, rejoue les migrations et lance le seed.",
    )
    parser.add_argument(
        "--org-id",
        required=True,
        help="identifiant de l'organisation « Dev » (ligne ORG_ID=… de `bun run db:seed`)",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    url = str(get_settings().database_url)
    try:
        assert_local(url)
    except RuntimeError as exc:
        print(exc, file=sys.stderr)
        return 1
    # `alembic/env.py` appelle `asyncio.run()` : chaque étape async tourne dans sa propre boucle,
    # jamais sous une boucle déjà ouverte.
    asyncio.run(drop_public_schema(url))
    print("Base data vidée (schéma public recréé).")
    command.upgrade(Config("alembic.ini"), "head")
    asyncio.run(seed.run(args.org_id))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

Run (utilisateur) : `uv run pytest -q -W error` — Expected : `21 passed` (12 existants + 7 `test_db_reset` + 2 `test_seed`).

- [ ] **Step 5 : Lint** — `uvx ruff check . && uvx ruff format --check .` — Expected : vert (sinon `uvx ruff format .` ; la ligne `print(f"seed DATA …")` dépasse 100 caractères si elle n'est pas coupée : laisser ruff la reformater).

- [ ] **Step 6 : Vérification (utilisateur — exige Postgres ; `<id>` = l'`ORG_ID` noté en Task 2 Step 8)**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api
uv run python -m app.db_reset --org-id <id>
uv run python -m app.seed --org-id <id>
uv run python -m app.seed; echo "exit=$?"
psql -h localhost -U seoinsight -d data -tAc "SELECT version_num FROM alembic_version"
DATABASE_URL=postgresql+asyncpg://seoinsight:secret@db.example.com:5432/data uv run python -m app.db_reset --org-id <id>; echo "exit=$?"
```

Expected : `db_reset` → `Base data vidée …`, `Running upgrade  -> b86e39e2c13b, init`, `seed DATA : rien à créer pour l'organisation <id> …` ; `app.seed --org-id` → même ligne ; `app.seed` sans argument → usage argparse `the following arguments are required: --org-id` et `exit=2` ; psql → `b86e39e2c13b` ; la commande distante → `Refus : DATABASE_URL pointe sur 'db.example.com', pas sur localhost` et `exit=1` (pydantic-settings donne la priorité à l'environnement sur `.env`), sans aucune connexion tentée.

- [ ] **Step 7 : Commit et merge**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): app.db_reset et app.seed (--org-id) avec garde-fou localhost"
git checkout dev && git merge feature/api-db-scripts --ff-only && git push && git branch -d feature/api-db-scripts
```

---

### Task 4 : `api/` — `CurrentOrgDep` (404 Problem Details sans organisation)

**Files:**
- Create: `api/tests/test_org.py`
- Modify: `api/app/core/auth.py`, `api/tests/conftest.py`

**Interfaces:**
- Consumes : `CurrentUser`, `CurrentUserDep`, `AppError`.
- Produces : `CurrentOrgUser(CurrentUser)` avec `org_id: str` (jamais `None`) ; `get_current_org_user(user: CurrentUserDep) -> CurrentOrgUser` ; `CurrentOrgDep = Annotated[CurrentOrgUser, Depends(get_current_org_user)]`. Erreur : `404` Problem Details, `title` = `"Not Found"`, `detail` = `"Aucune organisation active"` (spec architecture §9 : pas de fuite d'existence). `CurrentUser` / `CurrentUserDep` restent inchangés ; ils seront absorbés par `Principal` lors de la voie 2.

- [ ] **Step 1 : Branche** — `git checkout -b feature/api-current-org dev`

- [ ] **Step 2 : Tests (échouent d'abord)**

Dans `api/tests/conftest.py`, remplacer la ligne `from app.core.auth import set_jwks_client  # noqa: E402` par :

```python
from app.core.auth import CurrentOrgDep, set_jwks_client  # noqa: E402
```

et ajouter, après la route `/_test/echo` :

```python
@_test_router.get("/org")
async def org(user: CurrentOrgDep) -> dict[str, str]:
    return {"org_id": user.org_id}
```

`api/tests/test_org.py` :

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from httpx import AsyncClient

from tests.test_me import make_token


async def test_org_dep_exposes_org_id(
    client: AsyncClient, test_private_key: Ed25519PrivateKey
) -> None:
    resp = await client.get(
        "/_test/org", headers={"Authorization": f"Bearer {make_token(test_private_key)}"}
    )
    assert resp.status_code == 200
    assert resp.json() == {"org_id": "org_1"}


async def test_org_dep_without_org_is_problem_404(
    client: AsyncClient, test_private_key: Ed25519PrivateKey
) -> None:
    token = make_token(test_private_key, org_id=None)
    resp = await client.get("/_test/org", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 404
    assert resp.headers["content-type"].startswith("application/problem+json")
    body = resp.json()
    assert body["title"] == "Not Found"
    assert body["detail"] == "Aucune organisation active"


async def test_org_dep_without_token_is_401(client: AsyncClient) -> None:
    resp = await client.get("/_test/org")
    assert resp.status_code == 401
    assert resp.headers["www-authenticate"] == "Bearer"
```

Run (utilisateur) : `uv run pytest -q -W error tests/test_org.py` — Expected : `ImportError: cannot import name 'CurrentOrgDep'`.

- [ ] **Step 3 : `CurrentOrgDep`** — dans `api/app/core/auth.py`, ajouter à la fin du fichier (après `CurrentUserDep = …`) :

```python
@dataclass(frozen=True)
class CurrentOrgUser(CurrentUser):
    org_id: str


def get_current_org_user(user: CurrentUserDep) -> CurrentOrgUser:
    # 404 et non 403 : ne pas révéler si une organisation existe (spec architecture §9).
    if user.org_id is None:
        raise AppError(404, "Not Found", "Aucune organisation active")
    return CurrentOrgUser(user_id=user.user_id, email=user.email, org_id=user.org_id)


CurrentOrgDep = Annotated[CurrentOrgUser, Depends(get_current_org_user)]
```

Run (utilisateur) : `uv run pytest -q -W error` — Expected : `24 passed`.

- [ ] **Step 4 : Lint** — `uvx ruff check . && uvx ruff format --check .` — Expected : vert.

- [ ] **Step 5 : Commit et merge**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): dépendance CurrentOrgDep, 404 Problem Details sans organisation active"
git checkout dev && git merge feature/api-current-org --ff-only && git push && git branch -d feature/api-current-org
```

---

### Task 5 : `api/` — registre des modèles pour Alembic et règles de migration

**Files:**
- Create: `api/app/db/models.py`, `api/tests/test_migrations.py`
- Modify: `api/alembic/env.py`, `CLAUDE.md`

**Interfaces:**
- Produces : `Base` ré-exporté par `app.db.models` (seul point d'import pour Alembic) ; test `test_migrations_match_models` qui échoue dès qu'un modèle importé n'a pas sa migration (ou l'inverse) ; section « Migrations et données de dev » de `CLAUDE.md`.

**Choix : registre `app/db/models.py`, pas d'import dans `app/db/base.py`.** Les futurs `app/sites/models.py` importeront `Base` depuis `app.db.base` ; si `base.py` importait à son tour les modèles, on aurait un import circulaire. Le registre est un module distinct, importé uniquement par `alembic/env.py` et les tests.

- [ ] **Step 1 : Branche** — `git checkout -b feature/api-models-registry dev`

- [ ] **Step 2 : Test (échoue d'abord)** — `api/tests/test_migrations.py` :

```python
from alembic.autogenerate import compare_metadata
from alembic.runtime.migration import MigrationContext
from sqlalchemy import Connection

from app.db.engine import get_engine
from app.db.models import Base


def _diff(connection: Connection) -> list[object]:
    return compare_metadata(MigrationContext.configure(connection), Base.metadata)


async def test_migrations_match_models() -> None:
    """Après `alembic upgrade head` (fixture de session), l'autogénération ne propose rien :
    chaque modèle importé dans app.db.models a sa migration, et réciproquement."""
    async with get_engine().connect() as conn:
        diff = await conn.run_sync(_diff)
    assert diff == [], f"Modèles et migrations divergent : {diff}"
```

Run (utilisateur) : `uv run pytest -q -W error tests/test_migrations.py` — Expected : `ModuleNotFoundError: No module named 'app.db.models'`.

- [ ] **Step 3 : `api/app/db/models.py`**

```python
"""Registre des modèles SQLAlchemy.

`alembic/env.py` importe `Base` d'ici (et non de `app.db.base`) pour que `Base.metadata`
contienne toutes les tables au moment de l'autogénération. Chaque module métier ajoute
une ligne quand il crée son `models.py`, par exemple :

    from app.sites import models as _sites  # noqa: F401

Un modèle absent d'ici est invisible pour Alembic ; `tests/test_migrations.py` le détecte.
"""

from app.db.base import Base

__all__ = ["Base"]
```

Dans `api/alembic/env.py`, remplacer `from app.db.base import Base` par `from app.db.models import Base`.

Run (utilisateur) : `uv run pytest -q -W error` — Expected : `25 passed`. Contre-épreuve de l'autogénération : `uv run alembic revision --autogenerate -m wip` — Expected : le fichier créé dans `alembic/versions/` a `upgrade()` et `downgrade()` vides (`pass`) ; le supprimer aussitôt (`rm alembic/versions/*_wip.py`) et vérifier `git status --short` → seuls `app/db/models.py`, `alembic/env.py`, `tests/test_migrations.py` apparaissent.

- [ ] **Step 4 : Règles de migration dans `CLAUDE.md`** — ajouter, après la section `## Règles projet` et avant `## Issues et tickets : Plane (obligatoire)` :

```markdown
## Migrations et données de dev (spec 2026-08-27 §3)

- Une seule définition par table, dans l'app qui la lit et l'écrit : AUTH → Drizzle (`web/lib/db/auth-schema.ts`, généré par la CLI Better Auth) ; DATA → SQLAlchemy + Alembic (`api/app/<module>/models.py`).
- **Pendant une feature** : AUTH → `bun run db:push` applique le schéma à la volée sur `auth`, aucun fichier de migration. DATA → modifier les modèles, puis `uv run alembic revision --autogenerate -m wip` + `uv run alembic upgrade head` ; ces révisions `wip` sont jetables (`uv run alembic downgrade <base>` + suppression des fichiers avant le squash).
- **Fin de feature, avant merge dans `dev`** : AUTH → `bun run db:generate` (une seule migration dans `web/drizzle/`) puis `bun run db:reset` prouve que la chaîne rejoue de zéro. DATA → `uv run alembic downgrade <head de dev>`, suppression des `wip`, **une** révision nommée (`uv run alembic revision --autogenerate -m "sites"`), `uv run alembic upgrade head`, `uv run pytest -q -W error` rejoue la chaîne sur `data_test`.
- **Règles** : une feature = au plus une migration par base ; jamais de migration commitée sans son modèle — tout `models.py` est importé dans `api/app/db/models.py`, sinon Alembic ne le voit pas, et `tests/test_migrations.py` échoue si modèles et migrations divergent ; jamais de `push` ni d'autogénération en CI/prod ; la review lit le SQL généré (pas de `DROP` accidentel, index et partitions explicites — spec architecture §8).
- **Données de dev** : `bun run db:seed` (utilisateur `dev@seoinsight.local` / `dev-password!`, org « Dev ») imprime `ORG_ID=<id>` ; `uv run python -m app.seed --org-id <id>` (no-op tant que Sites n'existe pas). Rien n'est partagé entre les bases : l'id passe toujours par l'argument. `bun run db:reset` et `uv run python -m app.db_reset --org-id <id>` (drop → migrate → seed) sont les seules commandes destructrices ; elles refusent toute `DATABASE_URL` dont l'hôte n'est pas `localhost` / `127.0.0.1`.
- **Accès aux données dans `api/`** : tout handler qui lit ou écrit DATA prend `CurrentOrgDep` (`app.core.auth`) et filtre par `user.org_id` ; `CurrentUserDep` ne sert qu'aux endpoints sans organisation (`/me`).
```

- [ ] **Step 5 : Lint et suivi Plane** — `uvx ruff check . && uvx ruff format --check .` — Expected : vert. Si les issues Plane SEOINSIGHT-15 (CurrentOrgDep) et SEOINSIGHT-16 existent, les passer en « Done » via le serveur MCP Plane avec un commentaire pointant ce plan ; SEOINSIGHT-34 (CI qui teste `db-init.sh`) reste ouverte.

- [ ] **Step 6 : Commit et merge**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): registre des modèles pour Alembic, test modèles/migrations et règles de migration dans CLAUDE.md"
git checkout dev && git merge feature/api-models-registry --ff-only && git push && git branch -d feature/api-models-registry
```

---

## Critère de fin

- `compose.yaml` et `docker/` absents ; `mise run db-init` exécuté deux fois de suite sans erreur ; `advmfr_hr` intacte ; `.gitignore` sans `pgdata/` ni `web/.env.example`.
- `web/` : `bun run lint`, `bun run format:check`, `bun run test` (8 tests), `bun run build` verts ; `bun run db:reset` puis connexion `dev@seoinsight.local` / `dev-password!` sous `d3k` → `/dashboard` avec l'org « Dev », `d3k errors` vide ; `bun run db:seed` se termine par `ORG_ID=<id>` ; `bun lib/db/reset.ts` avec une URL distante → `exit=1`.
- `api/` : `uvx ruff check .`, `uv run pytest -q -W error` → `25 passed` ; `uv run python -m app.db_reset --org-id <id>` avec une URL distante → `exit=1` sans connexion ; `app.seed` sans `--org-id` → `exit=2` ; `alembic revision --autogenerate` sur `dev` produit une révision vide.
- `README.md`, `CLAUDE.md`, `mise.toml`, la spec d'architecture et `docs/pre-prod-checklist.md` ne mentionnent plus Docker pour le dev.

## Décisions intégrées

1. **`org_id` « Dev » → `app.seed`** : `bun run db:seed` imprime `ORG_ID=<id>` en dernière ligne ; `app.seed` et `app.db_reset` exigent `--org-id <id>` (argparse, testé). Rien n'est partagé entre les bases.
2. **`mise.toml`** : section `[tasks]` avec `db-init = "scripts/db-init.sh"` ; `mise run db-init` est la commande documentée dans README et CLAUDE.md.
3. **Spec d'architecture 2026-08-26** : amendée dans la Task 1 (§4 arbre et dev local, §10 tests, §12 phase 0) avec le texte exact des lignes remplacées.
4. **`.gitignore`** : la ligne `web/.env.example` est retirée dans la Task 1 (fichier suivi, qui doit le rester), en même temps que `pgdata/`.
