# Socle `web/` + `api/` — Plan d'implémentation (phases 1–2)

> **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 :** un utilisateur s'inscrit, se connecte (email/mdp, magic link, 2FA), crée une organisation dans `web/`, et `web/` appelle `api/` avec un JWT Better Auth que FastAPI vérifie via JWKS — `GET /me` fonctionne de bout en bout.

**Architecture :** `web/` (Next 16, Vercel) possède la base AUTH via Drizzle + Better Auth et ne fait que l'UI et l'auth. `api/` (FastAPI, Dedibox) possède la base DATA via SQLAlchemy + Alembic et vérifie les JWT signés par `web/`. Le seul lien est le JWT (EdDSA, JWKS publique). Aucune jointure ni connexion croisée.

**Tech Stack :** bun, Next 16.2, React 19, shadcn (Base UI), Better Auth 1.7 (`organization`, `magicLink`, `twoFactor`, `jwt`), Drizzle 0.45 + postgres.js, T3 Env + zod 4, Resend, oxlint/oxfmt, vitest · uv, Python 3.13, FastAPI 0.136, SQLAlchemy 2 async + asyncpg, Alembic, pydantic-settings, PyJWT (+ `cryptography`), pytest + pytest-asyncio + httpx, ruff · Postgres 17 (Docker en dev).

**Spec :** `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`.
- **`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.
- **Erreurs API** : Problem Details RFC 9457, `Content-Type: application/problem+json`.
- **Lint** : `bun run lint` (oxlint) et `bun run format:check` (oxfmt) verts avant chaque commit web ; `uvx ruff check .` et `uvx ruff format --check .` verts avant chaque commit api.
- **Git** : branche `feature/<nom>` depuis `dev`, Conventional Commits, merge `--ff-only` dans `dev`, jamais de commit sur `main`.
- Toute dette ou valeur temporaire est notée immédiatement dans `docs/pre-prod-checklist.md`.

---

## Carte des fichiers

### `web/`

| Fichier | Responsabilité |
|---|---|
| `env.ts` | Validation T3 Env de toutes les variables (serveur + client) |
| `next.config.ts` | Import de `env.ts`, `optimizePackageImports` |
| `.env.example` | Liste des variables, sans valeurs secrètes |
| `drizzle.config.ts` | Config drizzle-kit (dialect `postgresql`, schéma auth) |
| `lib/db/index.ts` | Client Drizzle singleton (postgres.js) → base AUTH |
| `lib/db/auth-schema.ts` | **Généré** par `bunx auth@latest generate` — jamais édité à la main |
| `drizzle/` | Migrations SQL générées par `drizzle-kit generate` |
| `lib/auth.ts` | Config serveur Better Auth (adapter, plugins, `sendMagicLink`) |
| `lib/auth-client.ts` | `createAuthClient` + plugins client |
| `lib/email.ts` | Envoi d'email via Resend (une fonction) |
| `lib/session.ts` | `requireSession()` : lit la session côté serveur ou redirige |
| `lib/api.ts` | Client `fetch` typé vers `api/` avec JWT (serveur uniquement) |
| `app/api/auth/[...all]/route.ts` | Handler Better Auth |
| `proxy.ts` | Redirection optimiste (cookie de session absent → `/sign-in`) |
| `app/(auth)/sign-in/page.tsx`, `sign-up/page.tsx`, `two-factor/page.tsx` | Écrans d'auth (Client Components aux feuilles) |
| `app/(auth)/layout.tsx` | Mise en page centrée des écrans d'auth |
| `app/(app)/layout.tsx` | Coquille protégée : `requireSession()`, en-tête avec org active + déconnexion |
| `app/(app)/dashboard/page.tsx` | Page d'accueil connectée : affiche `GET /me` de l'API |
| `app/(app)/onboarding/page.tsx` | Création de la première organisation |
| `app/(app)/settings/security/page.tsx` | Activation / désactivation 2FA |
| `app/(app)/{loading,error,not-found}.tsx` | Fichiers de route obligatoires |
| `components/auth/*.tsx` | Formulaires (sign-in, sign-up, magic-link, two-factor, create-org) |
| `components/ui/*` | **Générés** par shadcn — jamais édités à la main |
| `vitest.config.ts`, `lib/**/*.test.ts` | Tests des fonctions de `lib/` |
| `.vercelignore` | Exclusions du déploiement Vercel |

### `api/`

| Fichier | Responsabilité |
|---|---|
| `pyproject.toml` | Généré par `uv init`, deps, config ruff/pytest |
| `app/main.py` | Création de l'app, `lifespan`, montage des routers, handler d'erreurs |
| `app/core/config.py` | `Settings` pydantic-settings (échec si variable absente) |
| `app/core/errors.py` | `AppError` + handler Problem Details |
| `app/core/auth.py` | Vérification JWT (PyJWT + `PyJWKClient`), dépendance `CurrentUser` |
| `app/db/engine.py` | `create_async_engine`, `async_sessionmaker`, dépendance `get_session` |
| `app/db/base.py` | `DeclarativeBase` commune |
| `app/health/router.py` | `GET /health` (vérifie `SELECT 1`) |
| `app/me/router.py` | `GET /me` → identité extraite du JWT |
| `alembic/`, `alembic.ini` | Générés par `uvx alembic init -t async alembic`, `env.py` adapté |
| `tests/conftest.py` | Env de test (`data_test`), migrations, client httpx, clés JWT de test |
| `tests/test_health.py`, `tests/test_me.py`, `tests/test_errors.py` | Tests d'intégration |
| `Dockerfile` | Image de prod (uv, non-root) |
| `.env.example` | Variables requises |

---

## Phase 1 — `web/`

### Task 1 : Outillage `web/` (skills, oxlint, vitest, `.vercelignore`, checklist)

**Files:**
- Create: `web/.oxlintrc.json` (remplace celui généré si présent), `web/vitest.config.ts`, `web/.vercelignore`, `docs/pre-prod-checklist.md`
- Modify: `web/package.json` (script `test`)

**Interfaces:**
- Produces : commande `bun run test` (vitest, `lib/**/*.test.ts`, environnement `node`) utilisée par toutes les tâches web suivantes.

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

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

- [ ] **Step 2 : Installer les skills Next/shadcn exigés par la règle projet** (depuis la racine du repo ; si `bunx skills` échoue, utiliser `npx skills`)

```bash
bunx skills add https://github.com/sickn33/antigravity-awesome-skills --skill nextjs-best-practices
bunx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill nextjs-performance
bunx skills add https://github.com/vercel-labs/next-skills --skill next-best-practices
bunx skills add https://github.com/shadcn/ui --skill shadcn
```

Vérifier avec `ls .claude/skills .agents/skills 2>/dev/null` où ils ont été installés ; les committer (ils font partie du repo).

- [ ] **Step 3 : Config oxlint explicite** — écrire `web/.oxlintrc.json` :

```json
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "plugins": ["react", "typescript", "nextjs", "import", "jsx-a11y"],
  "categories": { "correctness": "error", "suspicious": "warn" },
  "ignorePatterns": ["components/ui/**", ".next/**", "lib/db/auth-schema.ts"]
}
```

Run : `cd web && bun run lint` — Expected : `Found 0 warnings and 0 errors`.

- [ ] **Step 4 : Vitest** — `cd web && sfw bun add -d vitest`, puis écrire `web/vitest.config.ts` :

```ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["lib/**/*.test.ts"],
  },
});
```

Ajouter dans `web/package.json` → `"scripts"` : `"test": "vitest run"`.

- [ ] **Step 5 : Test de fumée** — écrire `web/lib/utils.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { cn } from "./utils";

describe("cn", () => {
  it("fusionne les classes Tailwind en gardant la dernière", () => {
    expect(cn("p-2", "p-4")).toBe("p-4");
  });
});
```

Run : `bun run test` — Expected : `1 passed`.

- [ ] **Step 6 : `.vercelignore` et checklist** — écrire `web/.vercelignore` :

```
.claude
.agents
*.test.ts
vitest.config.ts
```

Écrire `docs/pre-prod-checklist.md` :

```markdown
# Pre-prod checklist

Tout ce qui ne doit pas partir en prod en l'état. À vider avant tout merge `dev` → `main`.

| Date | Élément | Où | Action avant prod |
|---|---|---|---|
```

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

```bash
cd web && bun run lint && bun run format:check && bun run test && cd ..
git add -A && git commit -m "chore(web): oxlint, vitest, vercelignore, skills Next et checklist pre-prod"
git checkout dev && git merge feature/web-tooling --ff-only && git push && git branch -d feature/web-tooling
```

---

### Task 2 : T3 Env

**Files:**
- Create: `web/env.ts`, `web/.env.example`, `web/.env` (local, ignoré par git)
- Modify: `web/next.config.ts`

**Interfaces:**
- Produces : `env` (import `@/env`) avec `env.DATABASE_URL`, `env.BETTER_AUTH_SECRET`, `env.BETTER_AUTH_URL`, `env.RESEND_API_KEY`, `env.EMAIL_FROM`, `env.API_URL`, `env.NEXT_PUBLIC_APP_URL`.

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

- [ ] **Step 2 : Dépendances** — `cd web && sfw bun add @t3-oss/env-nextjs zod`

- [ ] **Step 3 : Écrire `web/env.ts`**

```ts
import { createEnv } from "@t3-oss/env-nextjs";
import * as z from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.url().startsWith("postgres"),
    BETTER_AUTH_SECRET: z.string().min(32),
    BETTER_AUTH_URL: z.url(),
    RESEND_API_KEY: z.string().min(1),
    EMAIL_FROM: z.email(),
    API_URL: z.url(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
    RESEND_API_KEY: process.env.RESEND_API_KEY,
    EMAIL_FROM: process.env.EMAIL_FROM,
    API_URL: process.env.API_URL,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
  emptyStringAsUndefined: true,
});
```

- [ ] **Step 4 : Importer dans `web/next.config.ts`** (remplacer le fichier) :

```ts
import "./env";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: [],
  },
};

export default nextConfig;
```

- [ ] **Step 5 : `.env.example`** :

```
DATABASE_URL=postgres://seoinsight:seoinsight@localhost:5432/auth
BETTER_AUTH_SECRET=            # bunx auth@latest secret
BETTER_AUTH_URL=http://localhost:3000
RESEND_API_KEY=
EMAIL_FROM=noreply@example.com
API_URL=http://localhost:8000
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

Copier en `.env` et remplir (`BETTER_AUTH_SECRET` via `bunx auth@latest secret`, clé Resend fournie par l'utilisateur — **demander la clé, ne pas inventer**).

- [ ] **Step 6 : Vérifier que la validation bloque** — `mv .env .env.bak && bun run build` — Expected : échec avec `Invalid environment variables` listant les clés manquantes. Puis `mv .env.bak .env && bun run build` — Expected : build OK.

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

```bash
bun run lint && bun run format:check && cd .. && git add -A && git commit -m "feat(web): validation des variables d'environnement avec T3 Env"
git checkout dev && git merge feature/web-env --ff-only && git push && git branch -d feature/web-env
```

---

### Task 3 : Drizzle → base AUTH + Better Auth serveur + handler

**Files:**
- Create: `web/drizzle.config.ts`, `web/lib/db/index.ts`, `web/lib/db/auth-schema.ts` (généré), `web/lib/auth.ts`, `web/lib/email.ts`, `web/app/api/auth/[...all]/route.ts`, `web/drizzle/*` (généré)
- Modify: `web/package.json` (scripts `db:generate`, `db:migrate`), `web/next.config.ts` (`optimizePackageImports`)

**Interfaces:**
- Produces : `auth` (import `@/lib/auth`) avec `auth.api.getSession({ headers })`, `auth.api.getToken({ headers })` ; `db` (import `@/lib/db`) ; `sendEmail({ to, subject, html })` (import `@/lib/email`).

- [ ] **Step 1 : Branche et Postgres** — `git checkout -b feature/web-auth dev && docker compose up -d && docker compose ps` — Expected : service `postgres` `healthy`.

- [ ] **Step 2 : Dépendances** — `cd web && sfw bun add better-auth @better-auth/drizzle-adapter drizzle-orm postgres resend && sfw bun add -d drizzle-kit`

(Vérifié le 2026-08-26 : `better-auth`, `@better-auth/drizzle-adapter` et la CLI `auth` sont en 1.7.1 ; la doc officielle importe l'adapter depuis `@better-auth/drizzle-adapter`.)

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

```ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "@/env";
import * as schema from "./auth-schema";

const globalForDb = globalThis as unknown as { conn?: postgres.Sql };

const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (process.env.NODE_ENV !== "production") globalForDb.conn = conn;

export const db = drizzle(conn, { schema });
```

(`auth-schema.ts` n'existe pas encore : créer un fichier vide `export {};` temporaire, il sera écrasé à l'étape 6.)

- [ ] **Step 4 : `web/lib/email.ts`** :

```ts
import { Resend } from "resend";
import { env } from "@/env";

const resend = new Resend(env.RESEND_API_KEY);

export async function sendEmail(input: { to: string; subject: string; html: string }) {
  const { error } = await resend.emails.send({ from: env.EMAIL_FROM, ...input });
  if (error) throw new Error(`Resend: ${error.message}`);
}
```

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

```ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { jwt, magicLink, organization, twoFactor } from "better-auth/plugins";
import { env } from "@/env";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/auth-schema";
import { sendEmail } from "@/lib/email";

export const auth = betterAuth({
  baseURL: env.BETTER_AUTH_URL,
  secret: env.BETTER_AUTH_SECRET,
  database: drizzleAdapter(db, { provider: "pg", schema }),
  emailAndPassword: { enabled: true },
  plugins: [
    organization(),
    twoFactor(),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendEmail({
          to: email,
          subject: "Votre lien de connexion seoinsight",
          html: `<p>Cliquez pour vous connecter : <a href="${url}">${url}</a></p>`,
        });
      },
    }),
    jwt({
      jwt: {
        expirationTime: "15m",
        definePayload: ({ user, session }) => ({
          email: user.email,
          org_id: session.activeOrganizationId ?? null,
        }),
      },
    }),
  ],
});
```

Valeurs par défaut Better Auth (doc du plugin jwt) : `sub` = id utilisateur, `iss` = `aud` = `baseURL`, expiration 15 min, clé EdDSA/Ed25519. On garde `expirationTime: "15m"` explicite.

- [ ] **Step 6 : Générer le schéma Drizzle avec la CLI Better Auth**

```bash
bunx auth@latest generate --config lib/auth.ts --output lib/db/auth-schema.ts
```

Expected : `lib/db/auth-schema.ts` contient `user`, `session`, `account`, `verification`, `organization`, `member`, `invitation`, `twoFactor`, `jwks`. Ne jamais éditer ce fichier à la main.

- [ ] **Step 7 : `web/drizzle.config.ts`** :

```ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./lib/db/auth-schema.ts",
  out: "./drizzle",
  dbCredentials: { url: process.env.DATABASE_URL! },
});
```

Ajouter dans `package.json` → `scripts` : `"db:generate": "drizzle-kit generate"`, `"db:migrate": "drizzle-kit migrate"`.

- [ ] **Step 8 : Générer et appliquer la migration** — `bun run db:generate && bun run db:migrate` — Expected : dossier `drizzle/0000_*.sql` créé, puis `docker compose exec postgres psql -U seoinsight -d auth -c '\dt'` liste les tables ci-dessus.

- [ ] **Step 9 : Handler** — `web/app/api/auth/[...all]/route.ts` :

```ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

- [ ] **Step 10 : `optimizePackageImports`** — dans `next.config.ts`, `optimizePackageImports: ["better-auth", "drizzle-orm"]`.

- [ ] **Step 11 : Vérifier** — `bun run build` OK, puis lancer `d3k` (une seule fois, garder le process) et `curl -s http://localhost:3000/api/auth/ok` — Expected : `{"ok":true}`. `curl -s http://localhost:3000/api/auth/jwks` — Expected : JSON avec `keys[0].kty === "OKP"`, `crv === "Ed25519"`.

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

```bash
bun run lint && bun run format:check && bun run test && cd .. && git add -A
git commit -m "feat(web): Better Auth (orgs, magic link, 2FA, JWT) sur base AUTH via Drizzle"
git checkout dev && git merge feature/web-auth --ff-only && git push && git branch -d feature/web-auth
```

---

### Task 4 : Écrans d'authentification (sign-in, sign-up, magic link)

**Files:**
- Create: `web/lib/auth-client.ts`, `web/lib/session.ts`, `web/proxy.ts`, `web/app/(auth)/layout.tsx`, `web/app/(auth)/sign-in/page.tsx`, `web/app/(auth)/sign-up/page.tsx`, `web/components/auth/sign-in-form.tsx`, `web/components/auth/sign-up-form.tsx`, `web/components/auth/magic-link-form.tsx`, `web/app/(auth)/{loading,error,not-found}.tsx`
- Modify: `web/app/page.tsx` (redirige vers `/dashboard`)

**Interfaces:**
- Consumes : `auth` (Task 3).
- Produces : `authClient` (import `@/lib/auth-client`) ; `requireSession()` (import `@/lib/session`) → `Promise<{ user, session }>`, redirige vers `/sign-in` sinon.

- [ ] **Step 1 : Branche et composants shadcn**

```bash
git checkout -b feature/web-auth-screens dev
cd web && bunx --bun shadcn@latest add button card input label field separator alert
```

(Si `field` n'existe pas dans le registre : `bunx --bun shadcn@latest view field` pour vérifier, sinon utiliser `label` + `<p>` d'erreur.)

- [ ] **Step 2 : `web/lib/auth-client.ts`** :

```ts
import { createAuthClient } from "better-auth/react";
import {
  jwtClient,
  magicLinkClient,
  organizationClient,
  twoFactorClient,
} from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [organizationClient(), magicLinkClient(), twoFactorClient(), jwtClient()],
});
```

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

```ts
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function requireSession() {
  const result = await auth.api.getSession({ headers: await headers() });
  if (!result) redirect("/sign-in");
  return result;
}
```

- [ ] **Step 4 : `web/proxy.ts`** (redirection optimiste ; la vraie vérification est `requireSession()`) :

```ts
import { getSessionCookie } from "better-auth/cookies";
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  if (!getSessionCookie(request)) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/settings/:path*", "/onboarding"] };
```

- [ ] **Step 5 : Layout auth** — `web/app/(auth)/layout.tsx` :

```tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
  return <main className="flex min-h-svh items-center justify-center p-6">{children}</main>;
}
```

`loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement…</p>; }`
`not-found.tsx` : `export default function NotFound() { return <p>Page introuvable.</p>; }`
`error.tsx` :

```tsx
"use client";

export default function AuthError({ reset }: { error: Error; reset: () => void }) {
  return (
    <div className="space-y-2">
      <p>Une erreur est survenue.</p>
      <button type="button" onClick={reset} className="underline">Réessayer</button>
    </div>
  );
}
```

- [ ] **Step 6 : Formulaire de connexion** — `web/components/auth/sign-in-form.tsx` :

```tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function SignInForm() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setPending(true);
    setError(null);
    const form = new FormData(e.currentTarget);
    const { error } = await authClient.signIn.email({
      email: String(form.get("email")),
      password: String(form.get("password")),
    });
    setPending(false);
    if (error) return setError(error.message ?? "Connexion impossible");
    router.push("/dashboard");
  }

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input id="email" name="email" type="email" autoComplete="email" required />
      </div>
      <div className="space-y-2">
        <Label htmlFor="password">Mot de passe</Label>
        <Input id="password" name="password" type="password" autoComplete="current-password" required />
      </div>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
      <Button type="submit" disabled={pending} className="w-full">Se connecter</Button>
    </form>
  );
}
```

Si Better Auth répond que la 2FA est requise (`data.twoFactorRedirect === true` dans la réponse de `signIn.email`), faire `router.push("/two-factor")` — ce cas est complété dans la Task 6.

- [ ] **Step 7 : Formulaire magic link** — `web/components/auth/magic-link-form.tsx` :

```tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function MagicLinkForm() {
  const [sent, setSent] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const email = String(new FormData(e.currentTarget).get("email"));
    const { error } = await authClient.signIn.magicLink({ email, callbackURL: "/dashboard" });
    if (error) return setError(error.message ?? "Envoi impossible");
    setSent(true);
  }

  if (sent) return <p className="text-sm">Lien envoyé. Vérifiez votre boîte mail.</p>;

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="magic-email">Email</Label>
        <Input id="magic-email" name="email" type="email" autoComplete="email" required />
      </div>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
      <Button type="submit" variant="outline" className="w-full">Recevoir un lien de connexion</Button>
    </form>
  );
}
```

- [ ] **Step 8 : Formulaire d'inscription** — `web/components/auth/sign-up-form.tsx` :

```tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function SignUpForm() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setPending(true);
    setError(null);
    const form = new FormData(e.currentTarget);
    const { error } = await authClient.signUp.email({
      name: String(form.get("name")),
      email: String(form.get("email")),
      password: String(form.get("password")),
    });
    setPending(false);
    if (error) return setError(error.message ?? "Inscription impossible");
    router.push("/onboarding");
  }

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="name">Nom</Label>
        <Input id="name" name="name" autoComplete="name" required minLength={2} />
      </div>
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input id="email" name="email" type="email" autoComplete="email" required />
      </div>
      <div className="space-y-2">
        <Label htmlFor="password">Mot de passe</Label>
        <Input id="password" name="password" type="password" autoComplete="new-password" required minLength={8} />
      </div>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
      <Button type="submit" disabled={pending} className="w-full">Créer mon compte</Button>
    </form>
  );
}
```

- [ ] **Step 9 : Pages** — `web/app/(auth)/sign-in/page.tsx` :

```tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SignInForm } from "@/components/auth/sign-in-form";
import { MagicLinkForm } from "@/components/auth/magic-link-form";

export const metadata = { title: "Connexion — seoinsight" };

export default function SignInPage() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader><CardTitle>Connexion</CardTitle></CardHeader>
      <CardContent className="space-y-6">
        <SignInForm />
        <Separator />
        <MagicLinkForm />
        <p className="text-muted-foreground text-sm">
          Pas de compte ? <Link href="/sign-up" className="underline">Créer un compte</Link>
        </p>
      </CardContent>
    </Card>
  );
}
```

`sign-up/page.tsx` : même carte avec `SignUpForm` et lien vers `/sign-in`. `web/app/page.tsx` : `import { redirect } from "next/navigation"; export default function Home() { redirect("/dashboard"); }`.

- [ ] **Step 10 : Vérifier dans le navigateur (d3k)** — créer un compte sur `/sign-up`, se déconnecter (à ce stade, supprimer le cookie), se reconnecter sur `/sign-in`, envoyer un magic link et cliquer le lien reçu. Lire `d3k errors` — Expected : aucune erreur. `bun run build` OK.

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

```bash
bun run lint && bun run format:check && bun run test && cd .. && git add -A
git commit -m "feat(web): écrans de connexion, inscription et magic link"
git checkout dev && git merge feature/web-auth-screens --ff-only && git push && git branch -d feature/web-auth-screens
```

---

### Task 5 : Coquille connectée et organisations

**Files:**
- Create: `web/app/(app)/layout.tsx`, `web/app/(app)/{loading,error,not-found}.tsx`, `web/app/(app)/dashboard/page.tsx`, `web/app/(app)/onboarding/page.tsx`, `web/components/auth/create-org-form.tsx`, `web/components/app/header.tsx`, `web/components/app/sign-out-button.tsx`, `web/lib/org.ts`, `web/lib/org.test.ts`

**Interfaces:**
- Consumes : `requireSession()`, `auth`, `authClient`.
- Produces : `requireOrg()` (import `@/lib/org`) → session avec `activeOrganizationId` non nul, sinon redirige vers `/onboarding` ; `slugify(name)`.

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

- [ ] **Step 2 : Test de `slugify`** — `web/lib/org.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { slugify } from "./org";

describe("slugify", () => {
  it("normalise accents, espaces et casse", () => {
    expect(slugify("Agence Élévation SEO")).toBe("agence-elevation-seo");
  });
  it("supprime les caractères non alphanumériques", () => {
    expect(slugify("A/B & C!")).toBe("a-b-c");
  });
});
```

Run : `bun run test` — Expected : FAIL (`slugify` introuvable).

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

```ts
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";

export function slugify(name: string) {
  return name
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

export async function requireOrg() {
  const result = await requireSession();
  if (!result.session.activeOrganizationId) redirect("/onboarding");
  return { ...result, orgId: result.session.activeOrganizationId };
}
```

Run : `bun run test` — Expected : PASS.

- [ ] **Step 4 : Formulaire de création d'org** — `web/components/auth/create-org-form.tsx` :

```tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { slugify } from "@/lib/org";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function CreateOrgForm() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const name = String(new FormData(e.currentTarget).get("name")).trim();
    const { data, error } = await authClient.organization.create({ name, slug: slugify(name) });
    if (error || !data) return setError(error?.message ?? "Création impossible");
    await authClient.organization.setActive({ organizationId: data.id });
    router.push("/dashboard");
    router.refresh();
  }

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="name">Nom de l'organisation</Label>
        <Input id="name" name="name" required minLength={2} />
      </div>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
      <Button type="submit" className="w-full">Créer</Button>
    </form>
  );
}
```

- [ ] **Step 5 : Onboarding** — `web/app/(app)/onboarding/page.tsx` :

```tsx
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CreateOrgForm } from "@/components/auth/create-org-form";

export default async function OnboardingPage() {
  const { session } = await requireSession();
  if (session.activeOrganizationId) redirect("/dashboard");
  return (
    <Card className="mx-auto w-full max-w-sm">
      <CardHeader><CardTitle>Créez votre organisation</CardTitle></CardHeader>
      <CardContent><CreateOrgForm /></CardContent>
    </Card>
  );
}
```

- [ ] **Step 6 : En-tête et déconnexion** — `web/components/app/sign-out-button.tsx` :

```tsx
"use client";

import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";

export function SignOutButton() {
  const router = useRouter();
  return (
    <Button
      variant="ghost"
      onClick={async () => {
        await authClient.signOut();
        router.push("/sign-in");
      }}
    >
      Se déconnecter
    </Button>
  );
}
```

`web/components/app/header.tsx` (Server Component) :

```tsx
import Link from "next/link";
import { SignOutButton } from "./sign-out-button";

export function Header({ email, orgName }: { email: string; orgName: string | null }) {
  return (
    <header className="flex items-center justify-between border-b px-6 py-3">
      <Link href="/dashboard" className="font-semibold">seoinsight</Link>
      <div className="flex items-center gap-4 text-sm">
        {orgName ? <span>{orgName}</span> : null}
        <span className="text-muted-foreground">{email}</span>
        <Link href="/settings/security" className="underline">Sécurité</Link>
        <SignOutButton />
      </div>
    </header>
  );
}
```

- [ ] **Step 7 : Layout protégé** — `web/app/(app)/layout.tsx` :

```tsx
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { requireSession } from "@/lib/session";
import { Header } from "@/components/app/header";

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const { user, session } = await requireSession();
  const org = session.activeOrganizationId
    ? await auth.api.getFullOrganization({ headers: await headers() })
    : null;
  return (
    <div className="min-h-svh">
      <Header email={user.email} orgName={org?.name ?? null} />
      <main className="p-6">{children}</main>
    </div>
  );
}
```

Fichiers de route de `web/app/(app)/` :

`loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement…</p>; }`
`not-found.tsx` : `export default function NotFound() { return <p>Page introuvable.</p>; }`
`error.tsx` :

```tsx
"use client";

export default function AppError({ reset }: { error: Error; reset: () => void }) {
  return (
    <div className="space-y-2">
      <p>Une erreur est survenue.</p>
      <button type="button" onClick={reset} className="underline">Réessayer</button>
    </div>
  );
}
```

- [ ] **Step 8 : Dashboard provisoire** — `web/app/(app)/dashboard/page.tsx` :

```tsx
import { requireOrg } from "@/lib/org";

export const metadata = { title: "Tableau de bord — seoinsight" };

export default async function DashboardPage() {
  const { user, orgId } = await requireOrg();
  return (
    <section className="space-y-2">
      <h1 className="text-2xl font-semibold">Bonjour {user.name}</h1>
      <p className="text-muted-foreground text-sm">Organisation active : {orgId}</p>
    </section>
  );
}
```

- [ ] **Step 9 : Vérifier (d3k)** — nouveau compte → redirigé vers `/onboarding` → créer l'org → `/dashboard` affiche le nom de l'org dans l'en-tête ; se déconnecter → `/dashboard` redirige vers `/sign-in`. `d3k errors` vide. `bun run build` OK.

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

```bash
bun run lint && bun run format:check && bun run test && cd .. && git add -A
git commit -m "feat(web): coquille connectée, onboarding et organisation active"
git checkout dev && git merge feature/web-orgs --ff-only && git push && git branch -d feature/web-orgs
```

---

### Task 6 : 2FA (TOTP)

**Files:**
- Create: `web/app/(auth)/two-factor/page.tsx`, `web/components/auth/two-factor-form.tsx`, `web/app/(app)/settings/security/page.tsx`, `web/components/auth/two-factor-settings.tsx`, `web/app/(app)/settings/{loading,error,not-found}.tsx`
- Modify: `web/components/auth/sign-in-form.tsx` (redirection 2FA)

**Interfaces:**
- Consumes : `authClient.twoFactor.*`, `requireSession()`.

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

- [ ] **Step 2 : Redirection après connexion** — dans `sign-in-form.tsx`, remplacer la fin de `onSubmit` par :

```ts
const { data, error } = await authClient.signIn.email({ email, password });
setPending(false);
if (error) return setError(error.message ?? "Connexion impossible");
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) return router.push("/two-factor");
router.push("/dashboard");
```

- [ ] **Step 3 : Vérification du code** — `web/components/auth/two-factor-form.tsx` :

```tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function TwoFactorForm() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const code = String(new FormData(e.currentTarget).get("code"));
    const { error } = await authClient.twoFactor.verifyTotp({ code });
    if (error) return setError("Code invalide");
    router.push("/dashboard");
  }

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="code">Code à 6 chiffres</Label>
        <Input id="code" name="code" inputMode="numeric" pattern="[0-9]{6}" autoComplete="one-time-code" required />
      </div>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
      <Button type="submit" className="w-full">Vérifier</Button>
    </form>
  );
}
```

`web/app/(auth)/two-factor/page.tsx` :

```tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TwoFactorForm } from "@/components/auth/two-factor-form";

export const metadata = { title: "Double authentification — seoinsight" };

export default function TwoFactorPage() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader><CardTitle>Double authentification</CardTitle></CardHeader>
      <CardContent><TwoFactorForm /></CardContent>
    </Card>
  );
}
```

- [ ] **Step 4 : Activation dans les réglages** — `web/components/auth/two-factor-settings.tsx` :

```tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function TwoFactorSettings({ enabled }: { enabled: boolean }) {
  const [password, setPassword] = useState("");
  const [totpUri, setTotpUri] = useState<string | null>(null);
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [code, setCode] = useState("");
  const [message, setMessage] = useState<string | null>(null);

  async function enable() {
    const { data, error } = await authClient.twoFactor.enable({ password });
    if (error || !data) return setMessage(error?.message ?? "Activation impossible");
    setTotpUri(data.totpURI);
    setBackupCodes(data.backupCodes);
  }

  async function confirm() {
    const { error } = await authClient.twoFactor.verifyTotp({ code });
    setMessage(error ? "Code invalide" : "2FA activée. Rechargez la page.");
  }

  async function disable() {
    const { error } = await authClient.twoFactor.disable({ password });
    setMessage(error ? error.message ?? "Désactivation impossible" : "2FA désactivée. Rechargez la page.");
  }

  return (
    <div className="max-w-md space-y-4">
      <div className="space-y-2">
        <Label htmlFor="password">Mot de passe (requis)</Label>
        <Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      </div>
      {enabled ? (
        <Button variant="destructive" onClick={disable}>Désactiver la 2FA</Button>
      ) : (
        <Button onClick={enable}>Activer la 2FA</Button>
      )}
      {totpUri ? (
        <div className="space-y-2">
          <p className="text-sm">Ajoutez cette clé dans votre application d'authentification :</p>
          <code className="block break-all text-xs">{totpUri}</code>
          <p className="text-sm">Codes de secours : {backupCodes.join(", ")}</p>
          <Label htmlFor="code">Code de confirmation</Label>
          <Input id="code" value={code} onChange={(e) => setCode(e.target.value)} />
          <Button onClick={confirm}>Confirmer</Button>
        </div>
      ) : null}
      {message ? <p className="text-sm">{message}</p> : null}
    </div>
  );
}
```

(Le QR code viendra plus tard — noter dans `docs/pre-prod-checklist.md` : « 2FA : afficher un QR code au lieu de l'URI brute ».)

- [ ] **Step 5 : Page réglages** — `web/app/(app)/settings/security/page.tsx` :

```tsx
import { requireSession } from "@/lib/session";
import { TwoFactorSettings } from "@/components/auth/two-factor-settings";

export const metadata = { title: "Sécurité — seoinsight" };

export default async function SecurityPage() {
  const { user } = await requireSession();
  return (
    <section className="space-y-4">
      <h1 className="text-2xl font-semibold">Sécurité</h1>
      <TwoFactorSettings enabled={Boolean(user.twoFactorEnabled)} />
    </section>
  );
}
```

Ajouter dans `web/app/(app)/settings/` :

`loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement…</p>; }`
`not-found.tsx` : `export default function NotFound() { return <p>Page introuvable.</p>; }`
`error.tsx` :

```tsx
"use client";

export default function SettingsError({ reset }: { error: Error; reset: () => void }) {
  return (
    <div className="space-y-2">
      <p>Une erreur est survenue.</p>
      <button type="button" onClick={reset} className="underline">Réessayer</button>
    </div>
  );
}
```

- [ ] **Step 6 : Vérifier (d3k)** — activer la 2FA avec une app TOTP, se déconnecter, se reconnecter : redirection vers `/two-factor`, code accepté → `/dashboard`. Désactiver. `d3k errors` vide. `bun run build` OK.

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

```bash
bun run lint && bun run format:check && bun run test && cd .. && git add -A
git commit -m "feat(web): double authentification TOTP"
git checkout dev && git merge feature/web-2fa --ff-only && git push && git branch -d feature/web-2fa
```

---

## Phase 2 — `api/`

### Task 7 : Projet FastAPI, settings, erreurs Problem Details, `/health`

**Files:**
- Create: `api/` (via `uv init`), `api/app/__init__.py`, `api/app/main.py`, `api/app/core/__init__.py`, `api/app/core/config.py`, `api/app/core/errors.py`, `api/app/health/__init__.py`, `api/app/health/router.py`, `api/tests/__init__.py`, `api/tests/conftest.py`, `api/tests/test_health.py`, `api/tests/test_errors.py`, `api/.env.example`, `api/.env`
- Modify: `api/pyproject.toml` (ruff, pytest)

**Interfaces:**
- Produces : `settings` (`app.core.config`) avec `database_url`, `auth_base_url`, `jwks_url` ; `AppError(status, title, detail)` et `problem(status, title, detail)` (`app.core.errors`) ; `create_app()` (`app.main`) ; fixture `client` (httpx).

- [ ] **Step 1 : Branche et init uv**

```bash
git checkout -b feature/api-skeleton dev
cd /Users/humperdoo/Github/seoinsight-v2 && uv init --app --no-package --python 3.13 --name seoinsight-api api
cd api && rm -f main.py && mkdir -p app/core app/health tests && touch app/__init__.py app/core/__init__.py app/health/__init__.py tests/__init__.py
uv add "fastapi[standard]" pydantic-settings && uv add --dev pytest pytest-asyncio httpx ruff
```

`--no-package` = pas de `[build-system]` ; le package `app/` est importable depuis `api/` (cwd) par `uv run`, `fastapi dev` et pytest (`tests/__init__.py` + `rootdir`). Le `.gitignore` généré par `uv init` peut rester.

- [ ] **Step 2 : Config outils dans `pyproject.toml`** (ajouter) :

```toml
[tool.ruff]
target-version = "py313"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
testpaths = ["tests"]
```

- [ ] **Step 3 : Settings** — `app/core/config.py` :

```python
from functools import lru_cache

from pydantic import PostgresDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="forbid")

    database_url: PostgresDsn
    auth_base_url: str  # URL publique de web/ (issuer + audience des JWT)
    jwt_test_public_key: SecretStr | None = None  # tests uniquement

    @property
    def jwks_url(self) -> str:
        return f"{self.auth_base_url.rstrip('/')}/api/auth/jwks"


@lru_cache
def get_settings() -> Settings:
    return Settings()
```

`api/.env.example` :

```
DATABASE_URL=postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data
AUTH_BASE_URL=http://localhost:3000
```

Copier en `.env`.

- [ ] **Step 4 : Test des erreurs (échoue d'abord)** — `tests/test_errors.py` :

```python
from httpx import AsyncClient


async def test_app_error_is_problem_details(client: AsyncClient) -> None:
    resp = await client.get("/_test/boom")
    assert resp.status_code == 418
    assert resp.headers["content-type"].startswith("application/problem+json")
    body = resp.json()
    assert body["status"] == 418
    assert body["title"] == "Teapot"
    assert body["detail"] == "Je suis une théière"


async def test_validation_error_is_problem_details(client: AsyncClient) -> None:
    resp = await client.get("/_test/echo", params={"n": "abc"})
    assert resp.status_code == 422
    assert resp.headers["content-type"].startswith("application/problem+json")
    assert resp.json()["title"] == "Validation error"


async def test_unknown_route_is_problem_details(client: AsyncClient) -> None:
    resp = await client.get("/nope")
    assert resp.status_code == 404
    assert resp.headers["content-type"].startswith("application/problem+json")
```

`tests/conftest.py` (première version, complétée en Task 8/9) :

```python
import os

os.environ["DATABASE_URL"] = "postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data_test"
os.environ["AUTH_BASE_URL"] = "http://test-auth.local"

import pytest_asyncio
from fastapi import APIRouter
from httpx import ASGITransport, AsyncClient

from app.core.errors import AppError
from app.main import create_app

_test_router = APIRouter(prefix="/_test")


@_test_router.get("/boom")
async def boom() -> None:
    raise AppError(418, "Teapot", "Je suis une théière")


@_test_router.get("/echo")
async def echo(n: int) -> dict[str, int]:
    return {"n": n}


@pytest_asyncio.fixture(scope="session")
async def client():
    app = create_app()
    app.include_router(_test_router)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c
```

Run : `uv run pytest -q` — Expected : erreurs d'import (`app.core.errors`, `app.main` inexistants).

- [ ] **Step 5 : Erreurs** — `app/core/errors.py` :

```python
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

PROBLEM_JSON = "application/problem+json"


class AppError(Exception):
    def __init__(self, status: int, title: str, detail: str) -> None:
        super().__init__(detail)
        self.status = status
        self.title = title
        self.detail = detail


def problem(status: int, title: str, detail: str | None = None) -> JSONResponse:
    body: dict[str, object] = {"type": "about:blank", "title": title, "status": status}
    if detail:
        body["detail"] = detail
    return JSONResponse(status_code=status, content=body, media_type=PROBLEM_JSON)


def register_error_handlers(app: FastAPI) -> None:
    @app.exception_handler(AppError)
    async def _app_error(_: Request, exc: AppError) -> JSONResponse:
        return problem(exc.status, exc.title, exc.detail)

    @app.exception_handler(RequestValidationError)
    async def _validation(_: Request, exc: RequestValidationError) -> JSONResponse:
        return problem(422, "Validation error", str(exc.errors()[0]["msg"]) if exc.errors() else None)

    @app.exception_handler(StarletteHTTPException)
    async def _http(_: Request, exc: StarletteHTTPException) -> JSONResponse:
        return problem(exc.status_code, str(exc.detail))
```

- [ ] **Step 6 : App et `/health` (sans DB pour l'instant)** — `app/health/router.py` :

```python
from fastapi import APIRouter

router = APIRouter(tags=["health"])


@router.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}
```

`app/main.py` :

```python
from fastapi import FastAPI

from app.core.errors import register_error_handlers
from app.health.router import router as health_router


def create_app() -> FastAPI:
    app = FastAPI(title="seoinsight api", docs_url="/docs", redoc_url=None)
    register_error_handlers(app)
    app.include_router(health_router)
    return app


app = create_app()
```

`tests/test_health.py` :

```python
from httpx import AsyncClient


async def test_health(client: AsyncClient) -> None:
    resp = await client.get("/health")
    assert resp.status_code == 200
    assert resp.json() == {"status": "ok"}
```

Run : `uv run pytest -q` — Expected : `4 passed`.

- [ ] **Step 7 : Lint et lancement** — `uvx ruff check . && uvx ruff format .` puis `uv run fastapi dev app/main.py` et `curl -s localhost:8000/health` — Expected : `{"status":"ok"}`. Arrêter le serveur.

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

```bash
cd .. && git add -A && git commit -m "feat(api): squelette FastAPI, settings, erreurs Problem Details, /health"
git checkout dev && git merge feature/api-skeleton --ff-only && git push && git branch -d feature/api-skeleton
```

---

### Task 8 : SQLAlchemy async → base DATA + Alembic

**Files:**
- Create: `api/app/db/__init__.py`, `api/app/db/base.py`, `api/app/db/engine.py`, `api/alembic.ini`, `api/alembic/` (généré), `api/tests/test_db.py`
- Modify: `api/alembic/env.py`, `api/app/main.py` (lifespan), `api/app/health/router.py` (`SELECT 1`), `api/tests/conftest.py` (migrations)

**Interfaces:**
- Produces : `Base` (`app.db.base`) ; `get_session()` dépendance → `AsyncSession` ; `SessionDep = Annotated[AsyncSession, Depends(get_session)]` ; migrations `uv run alembic upgrade head`.

- [ ] **Step 1 : Branche et deps** — `git checkout -b feature/api-db dev && cd api && uv add "sqlalchemy[asyncio]" asyncpg alembic`

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

```python
from httpx import AsyncClient


async def test_health_checks_database(client: AsyncClient) -> None:
    resp = await client.get("/health")
    assert resp.json() == {"status": "ok", "database": "ok"}
```

Run : `uv run pytest -q tests/test_db.py` — Expected : FAIL (clé `database` absente).

- [ ] **Step 3 : Engine et session** — `app/db/base.py` :

```python
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass
```

`app/db/engine.py` :

```python
from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine

from app.core.config import get_settings

_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None


def get_engine() -> AsyncEngine:
    global _engine, _session_factory
    if _engine is None:
        _engine = create_async_engine(str(get_settings().database_url), pool_pre_ping=True)
        _session_factory = async_sessionmaker(_engine, expire_on_commit=False)
    return _engine


async def get_session() -> AsyncIterator[AsyncSession]:
    get_engine()
    assert _session_factory is not None
    async with _session_factory() as session:
        yield session


async def dispose_engine() -> None:
    global _engine, _session_factory
    if _engine is not None:
        await _engine.dispose()
        _engine = None
        _session_factory = None


SessionDep = Annotated[AsyncSession, Depends(get_session)]
```

- [ ] **Step 4 : `/health` avec DB et lifespan** — `app/health/router.py` :

```python
from fastapi import APIRouter
from sqlalchemy import text

from app.db.engine import SessionDep

router = APIRouter(tags=["health"])


@router.get("/health")
async def health(session: SessionDep) -> dict[str, str]:
    await session.execute(text("SELECT 1"))
    return {"status": "ok", "database": "ok"}
```

Dans `app/main.py`, ajouter :

```python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from app.db.engine import dispose_engine, get_engine


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
    get_engine()
    yield
    await dispose_engine()
```

et passer `lifespan=lifespan` à `FastAPI(...)`. Mettre à jour `tests/test_health.py` pour attendre `{"status": "ok", "database": "ok"}`.

- [ ] **Step 5 : Alembic** — `uvx --from alembic alembic init -t async alembic` (dans `api/`). Dans `alembic/env.py`, après les imports, remplacer la lecture d'URL et le metadata :

```python
from app.core.config import get_settings
from app.db.base import Base

config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
target_metadata = Base.metadata
```

Dans `alembic.ini`, laisser `sqlalchemy.url` vide (il est fixé par `env.py`).

- [ ] **Step 6 : Première migration** — `uv run alembic revision -m "init" && uv run alembic upgrade head` — Expected : table `alembic_version` présente dans `data` (`docker compose exec postgres psql -U seoinsight -d data -c '\dt'`).

- [ ] **Step 7 : Migrations dans les tests** — ajouter dans `tests/conftest.py` (après les `os.environ[...]`) :

```python
import pytest
from alembic import command
from alembic.config import Config


@pytest.fixture(scope="session", autouse=True)
def _migrate_test_db() -> None:
    cfg = Config("alembic.ini")
    command.upgrade(cfg, "head")
```

Run : `uv run pytest -q` — Expected : tous verts (`5 passed`), la base `data_test` contient `alembic_version`.

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

```bash
uvx ruff check . && uvx ruff format . && cd .. && git add -A
git commit -m "feat(api): SQLAlchemy async vers la base DATA et migrations Alembic"
git checkout dev && git merge feature/api-db --ff-only && git push && git branch -d feature/api-db
```

---

### Task 9 : Vérification JWT et `GET /me`

**Files:**
- Create: `api/app/core/auth.py`, `api/app/me/__init__.py`, `api/app/me/router.py`, `api/tests/test_me.py`
- Modify: `api/app/main.py` (router), `api/tests/conftest.py` (clés de test)

**Interfaces:**
- Consumes : `settings.jwks_url`, `settings.auth_base_url`, `AppError`.
- Produces : `CurrentUser` dataclass (`user_id: str`, `email: str`, `org_id: str | None`) ; `CurrentUserDep = Annotated[CurrentUser, Depends(get_current_user)]` ; `GET /me` → `{"user_id", "email", "org_id"}`.

- [ ] **Step 1 : Branche et deps** — `git checkout -b feature/api-jwt dev && cd api && uv add "pyjwt[crypto]"`

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

```python
import time

import jwt
from httpx import AsyncClient


def make_token(private_key, *, sub="user_1", org_id="org_1", exp_delta=900, iss=None, aud=None):
    now = int(time.time())
    payload = {
        "sub": sub,
        "email": "a@b.c",
        "org_id": org_id,
        "iat": now,
        "exp": now + exp_delta,
        "iss": iss or "http://test-auth.local",
        "aud": aud or "http://test-auth.local",
    }
    return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": "test-key"})


async def test_me_with_valid_token(client: AsyncClient, test_private_key) -> None:
    resp = await client.get("/me", headers={"Authorization": f"Bearer {make_token(test_private_key)}"})
    assert resp.status_code == 200
    assert resp.json() == {"user_id": "user_1", "email": "a@b.c", "org_id": "org_1"}


async def test_me_without_org(client: AsyncClient, test_private_key) -> None:
    token = make_token(test_private_key, org_id=None)
    resp = await client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 200
    assert resp.json()["org_id"] is None


async def test_me_without_token(client: AsyncClient) -> None:
    resp = await client.get("/me")
    assert resp.status_code == 401
    assert resp.headers["content-type"].startswith("application/problem+json")


async def test_me_expired_token(client: AsyncClient, test_private_key) -> None:
    token = make_token(test_private_key, exp_delta=-10)
    resp = await client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 401


async def test_me_wrong_issuer(client: AsyncClient, test_private_key) -> None:
    token = make_token(test_private_key, iss="http://evil.local")
    resp = await client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 401


async def test_me_wrong_key(client: AsyncClient) -> None:
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

    token = make_token(Ed25519PrivateKey.generate())
    resp = await client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 401
```

Ajouter dans `tests/conftest.py` :

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from jwt import PyJWK


@pytest.fixture(scope="session")
def test_private_key():
    return Ed25519PrivateKey.generate()


class _FakeJwksClient:
    def __init__(self, private_key: Ed25519PrivateKey) -> None:
        self._key = PyJWK.from_json(
            jwt.algorithms.OKPAlgorithm.to_jwk(private_key.public_key()), algorithm="EdDSA"
        )

    def get_signing_key_from_jwt(self, _token: str) -> PyJWK:
        return self._key
```

et remplacer la fixture `client` par (ajouter `import jwt` et `from app.core.auth import set_jwks_client` en haut du conftest) :

```python
@pytest_asyncio.fixture(scope="session")
async def client(test_private_key):
    set_jwks_client(_FakeJwksClient(test_private_key))
    app = create_app()
    app.include_router(_test_router)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c
```

Run : `uv run pytest -q tests/test_me.py` — Expected : erreurs d'import (`app.core.auth`).

- [ ] **Step 3 : Vérification JWT** — `app/core/auth.py` :

```python
from dataclasses import dataclass
from typing import Annotated, Protocol

import jwt
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWK, PyJWKClient

from app.core.config import get_settings
from app.core.errors import AppError

ALGORITHMS = ["EdDSA"]


class JwksClient(Protocol):
    def get_signing_key_from_jwt(self, token: str) -> PyJWK: ...


_jwks_client: JwksClient | None = None


def set_jwks_client(client: JwksClient) -> None:
    global _jwks_client
    _jwks_client = client


def get_jwks_client() -> JwksClient:
    global _jwks_client
    if _jwks_client is None:
        _jwks_client = PyJWKClient(get_settings().jwks_url, cache_jwk_set=True, lifespan=300)
    return _jwks_client


@dataclass(frozen=True)
class CurrentUser:
    user_id: str
    email: str
    org_id: str | None


_bearer = HTTPBearer(auto_error=False)


def get_current_user(
    creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
) -> CurrentUser:
    if creds is None:
        raise AppError(401, "Unauthorized", "Jeton manquant")
    settings = get_settings()
    try:
        key = get_jwks_client().get_signing_key_from_jwt(creds.credentials)
        claims = jwt.decode(
            creds.credentials,
            key.key,
            algorithms=ALGORITHMS,
            issuer=settings.auth_base_url,
            audience=settings.auth_base_url,
            options={"require": ["exp", "sub"]},
        )
    except jwt.PyJWTError as exc:
        raise AppError(401, "Unauthorized", "Jeton invalide") from exc
    return CurrentUser(
        user_id=str(claims["sub"]),
        email=str(claims.get("email", "")),
        org_id=claims.get("org_id") or None,
    )


CurrentUserDep = Annotated[CurrentUser, Depends(get_current_user)]
```

**Vérification obligatoire** : décoder un vrai JWT émis par `web/` (Task 10) et confirmer les valeurs réelles de `iss` et `aud` (Better Auth utilise `baseURL` pour les deux par défaut). Si `aud` est absent ou différent, ajuster `audience=` (ou `options={"verify_aud": False}` **uniquement** si Better Auth n'émet pas de `aud`, et le noter dans `docs/pre-prod-checklist.md`).

- [ ] **Step 4 : `GET /me`** — `app/me/router.py` :

```python
from fastapi import APIRouter

from app.core.auth import CurrentUserDep

router = APIRouter(tags=["me"])


@router.get("/me")
async def me(user: CurrentUserDep) -> dict[str, str | None]:
    return {"user_id": user.user_id, "email": user.email, "org_id": user.org_id}
```

Monter le router dans `create_app()`.

Run : `uv run pytest -q` — Expected : `11 passed`.

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

```bash
uvx ruff check . && uvx ruff format . && cd .. && git add -A
git commit -m "feat(api): vérification des JWT Better Auth via JWKS et GET /me"
git checkout dev && git merge feature/api-jwt --ff-only && git push && git branch -d feature/api-jwt
```

---

### Task 10 : `web/` → `api/` de bout en bout

**Files:**
- Create: `web/lib/api.ts`, `web/lib/api.test.ts`
- Modify: `web/app/(app)/dashboard/page.tsx`

**Interfaces:**
- Consumes : `auth.api.getToken`, `env.API_URL`, `GET /me`.
- Produces : `apiFetch<T>(path, init?)` (serveur uniquement) qui ajoute le JWT et convertit les Problem Details en `ApiError` ; `ApiError` avec `status`, `title`, `detail`.

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

- [ ] **Step 2 : Test (échoue d'abord)** — `web/lib/api.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { ApiError, parseProblem } from "./api";

describe("parseProblem", () => {
  it("construit une ApiError depuis un Problem Details", async () => {
    const res = new Response(JSON.stringify({ status: 404, title: "Not Found", detail: "Site inconnu" }), {
      status: 404,
      headers: { "content-type": "application/problem+json" },
    });
    const err = await parseProblem(res);
    expect(err).toBeInstanceOf(ApiError);
    expect(err.status).toBe(404);
    expect(err.title).toBe("Not Found");
    expect(err.detail).toBe("Site inconnu");
  });

  it("tolère une réponse sans corps JSON", async () => {
    const err = await parseProblem(new Response("boom", { status: 502 }));
    expect(err.status).toBe(502);
    expect(err.title).toBe("Bad Gateway");
  });
});
```

Run : `bun run test` — Expected : FAIL.

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

```ts
import "server-only";
import { headers } from "next/headers";
import { env } from "@/env";
import { auth } from "@/lib/auth";

export class ApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly title: string,
    public readonly detail?: string,
  ) {
    super(detail ?? title);
  }
}

export async function parseProblem(res: Response): Promise<ApiError> {
  try {
    const body = (await res.json()) as { status?: number; title?: string; detail?: string };
    return new ApiError(body.status ?? res.status, body.title ?? res.statusText, body.detail);
  } catch {
    return new ApiError(res.status, res.statusText);
  }
}

async function getJwt(): Promise<string> {
  const result = await auth.api.getToken({ headers: await headers() });
  if (!result?.token) throw new ApiError(401, "Unauthorized", "Session absente");
  return result.token;
}

export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
  const token = await getJwt();
  const res = await fetch(`${env.API_URL}${path}`, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: "application/json" },
    cache: "no-store",
  });
  if (!res.ok) throw await parseProblem(res);
  return (await res.json()) as T;
}
```

`auth.api.getToken` est l'endpoint `getToken` (`/token`) du plugin jwt — vérifié dans better-auth 1.7.1 (`dist/plugins/jwt/index.d.mts`). Il renvoie `{ token }`. `sfw bun add server-only` si le package n'est pas déjà présent.

Run : `bun run test` — Expected : PASS (les tests n'importent que `ApiError`/`parseProblem` ; si l'import de `server-only` casse vitest, déplacer `ApiError` et `parseProblem` dans `lib/api-error.ts` et les ré-exporter).

- [ ] **Step 4 : Dashboard consomme `/me`** — remplacer `web/app/(app)/dashboard/page.tsx` :

```tsx
import { apiFetch } from "@/lib/api";
import { requireOrg } from "@/lib/org";

export const metadata = { title: "Tableau de bord — seoinsight" };

type Me = { user_id: string; email: string; org_id: string | null };

export default async function DashboardPage() {
  const { user } = await requireOrg();
  const me = await apiFetch<Me>("/me");
  return (
    <section className="space-y-2">
      <h1 className="text-2xl font-semibold">Bonjour {user.name}</h1>
      <p className="text-muted-foreground text-sm">
        L'API vous reconnaît comme {me.email} dans l'organisation {me.org_id}.
      </p>
    </section>
  );
}
```

- [ ] **Step 5 : Vérification de bout en bout** — `docker compose up -d`, `cd api && uv run fastapi dev app/main.py` (terminal 1), `cd web && d3k` (terminal 2). Se connecter, ouvrir `/dashboard` — Expected : la phrase affiche l'email et l'org. Puis décoder un JWT réel : `curl -s -b "<cookie de session>" http://localhost:3000/api/auth/token` et vérifier `iss`/`aud` sur jwt.io ou avec `uv run python -c "import jwt,sys;print(jwt.decode(sys.argv[1],options={'verify_signature':False}))" <token>` — ajuster `app/core/auth.py` si nécessaire (voir Task 9 Step 3). `d3k errors` vide. `bun run build` OK.

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

```bash
bun run lint && bun run format:check && bun run test && cd .. && git add -A
git commit -m "feat(web): client API serveur avec JWT et affichage de /me"
git checkout dev && git merge feature/web-api-client --ff-only && git push && git branch -d feature/web-api-client
```

---

### Task 11 : Dockerfile `api/` et documentation

**Files:**
- Create: `api/Dockerfile`, `api/.dockerignore`
- Modify: `README.md` (racine), `docs/superpowers/specs/2026-08-26-seoinsight-v2-architecture-design.md` (§6 : `joserfc` → `PyJWT`)

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

- [ ] **Step 2 : `api/Dockerfile`** :

```dockerfile
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev

FROM python:3.13-slim-bookworm
WORKDIR /app
RUN useradd --create-home appuser
COPY --from=builder --chown=appuser:appuser /app /app
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
```

`api/.dockerignore` :

```
.venv
.env
.pytest_cache
.ruff_cache
__pycache__
tests
```

- [ ] **Step 3 : Vérifier** — `cd api && docker build -t seoinsight-api . && docker run --rm -e DATABASE_URL=postgresql+asyncpg://seoinsight:seoinsight@host.docker.internal:5432/data -e AUTH_BASE_URL=http://localhost:3000 -p 8001:8000 seoinsight-api` puis `curl -s localhost:8001/health` — Expected : `{"status":"ok","database":"ok"}`. Arrêter le conteneur.

- [ ] **Step 4 : Docs** — dans la spec §6, remplacer `` `joserfc` (JWT/JWKS) `` par `` `pyjwt[crypto]` (JWT + cache JWKS via `PyJWKClient`) ``. Dans `README.md` racine, section Dev : ajouter les variables à copier (`web/.env.example`, `api/.env.example`) et les deux commandes de migration (`bun run db:migrate`, `uv run alembic upgrade head`).

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

```bash
cd .. && git add -A && git commit -m "build(api): Dockerfile et documentation des migrations"
git checkout dev && git merge feature/api-docker --ff-only && git push && git branch -d feature/api-docker
```

---

## Critère de fin des phases 1–2

- `bun run lint`, `bun run format:check`, `bun run test`, `bun run build` verts dans `web/`.
- `uvx ruff check .`, `uv run pytest` (11 tests) verts dans `api/`.
- Parcours manuel sous d3k : inscription → onboarding (org) → dashboard affichant `/me` → 2FA activée → reconnexion avec code → magic link fonctionnel. `d3k errors` vide.
- `docs/pre-prod-checklist.md` liste au moins : QR code 2FA, et tout écart constaté sur `iss`/`aud`.
