# Mise en privé de la knowledge base, 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 :** Rendre `advmfr-knowledge` inaccessible sans compte, via une authentification par magic link et un filtrage du contenu à l'entrée du loader Fumadocs.

**Architecture :** Trois couches. `proxy.ts` redirige vers `/login` sur absence de cookie (ergonomie, pas sécurité). Chaque route serveur valide réellement la session via `auth.api.getSession`. Et `lib/source.ts` devient une factory `getSource(permission)` qui, pour un anonyme, construit un loader Fumadocs sans aucun fichier : le contenu n'est pas caché, il n'existe pas.

**Tech Stack :** Next.js 16.3 (App Router, `proxy.ts`), Fumadocs 16.14.4, Better Auth 1.6 (plugin `magicLink`, adaptateur intégré), MySQL 8 via `mysql2`, Resend, Zod.

**Spec :** `docs/superpowers/specs/2026-08-18-access-control-prive-design.md`

## Global Constraints

- **Pas de tests unitaires.** Décision prise dans la spec : le projet n'a aucun harnais et n'en gagne pas un ici. Chaque tâche se termine par une **vérification exécutable** dont la sortie attendue est écrite noir sur blanc. La validation de bout en bout se fait sous **d3k**, jamais avec un navigateur ou un profil tiers, le profil Chrome managé de d3k est ce qui préserve la session entre deux étapes.
- **Aucune ossature écrite à la main.** Better Auth est initialisé par `bunx @better-auth/cli@latest init`, le schéma est créé par `migrate`. On ajuste ce que le CLI produit, on ne le réécrit pas.
- **Toute nouvelle dépendance passe par `sfw`** : `sfw bun add <pkg>`.
- **Un seul niveau d'accès** : `anonymous` ou `member`. Pas de rôles, pas de frontmatter `permission`.
- **`lib/env.ts` ne doit jamais être importé depuis un composant client**: il lit `process.env` à l'import et ferait fuiter les secrets dans le bundle.
- **Commits `dev` uniquement**, format Conventional Commits, jamais sur `main`.
- Zod : n'utiliser que `z.string()` / `.min()`, aucune API dont la forme diffère entre v3 et v4.

## Structure des fichiers

**Créés**

| Fichier | Responsabilité |
|---|---|
| `docker-compose.yml` | MySQL 8 de développement, port 3307 |
| `.env.example` | Inventaire des variables, sans valeurs |
| `lib/env.ts` | Validation Zod des variables au démarrage |
| `lib/db.ts` | Le pool `mysql2`, unique dans tout le processus |
| `lib/users.ts` | L'allowlist : `isInvited`, `inviteUser` |
| `lib/email.ts` | Envoi du magic link via Resend |
| `lib/auth.ts` | Instance Better Auth (généré par le CLI, puis ajusté) |
| `lib/auth-client.ts` | Client React (généré par le CLI) |
| `lib/session.ts` | `getCurrentSession`, `requirePermission` |
| `app/api/auth/[...all]/route.ts` | Handler Better Auth (généré par le CLI) |
| `app/login/page.tsx` | Écran de connexion (serveur) |
| `app/login/login-form.tsx` | Formulaire (client) |
| `components/sign-out-button.tsx` | Déconnexion |
| `app/robots.ts` | Interdiction d'indexation |
| `scripts/invite.ts` | Création d'un utilisateur = invitation |
| `docs/deploiement-o2switch.md` | Procédure d'hébergement, documentée sans être exécutée |

**Modifiés**

| Fichier | Modification |
|---|---|
| `lib/source.ts` | `source` → factory `getSource(permission)` mémoïsée |
| `lib/layout.shared.tsx` | Ajout du bouton de déconnexion dans la nav |
| `proxy.ts` | Contrôle optimiste du cookie + `config.matcher` |
| `next.config.mjs` | En-tête `X-Robots-Tag` |
| `app/(home)/page.tsx` | Session requise |
| `app/docs/layout.tsx` | Arbre issu de `getSource` |
| `app/docs/[[...slug]]/page.tsx` | Session requise, `generateStaticParams` supprimé |
| `app/api/search/route.ts` | 401 sans session |
| `app/llms.txt/route.ts` | 401 sans session |
| `app/llms-full.txt/route.ts` | 401 sans session |
| `app/llms.mdx/docs/[[...slug]]/route.ts` | 401 sans session, `generateStaticParams` supprimé |
| `app/og/docs/[...slug]/route.tsx` | 401 sans session, `generateStaticParams` supprimé |
| `package.json` | Scripts `db:up`, `db:down`, `invite` |

---

### Task 1 : Base de développement et variables d'environnement

**Files:**
- Create: `docker-compose.yml`, `.env.example`, `.env`, `lib/env.ts`, `lib/db.ts`
- Modify: `package.json`

**Interfaces:**
- Consomme : rien.
- Produit : `env` (objet validé, champs `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `RESEND_API_KEY`, `MAGIC_LINK_FROM`, tous `string`) exporté par `lib/env.ts` ; `db` (pool `mysql2/promise`) exporté par `lib/db.ts`.

- [ ] **Step 1 : Installer les dépendances**

```bash
sfw bun add better-auth mysql2 resend zod
```

- [ ] **Step 2 : Écrire `docker-compose.yml`**

Port 3307 côté hôte, pour ne pas entrer en conflit avec un MySQL déjà installé.

```yaml
services:
  mysql:
    image: mysql:8
    container_name: advmfr-knowledge-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: knowledge
      MYSQL_DATABASE: knowledge
      MYSQL_USER: knowledge
      MYSQL_PASSWORD: knowledge
    ports:
      - '3307:3306'
    volumes:
      - mysql-data:/var/lib/mysql

volumes:
  mysql-data:
```

- [ ] **Step 3 : Ajouter les scripts à `package.json`**

Dans `"scripts"` :

```json
"db:up": "docker compose up -d",
"db:down": "docker compose down"
```

- [ ] **Step 4 : Démarrer la base et vérifier**

```bash
bun run db:up
docker exec advmfr-knowledge-mysql mysqladmin ping -uknowledge -pknowledge
```

Attendu : `mysqld is alive`. Si le conteneur vient d'être créé, attendre quelques secondes et relancer, MySQL initialise son volume au premier démarrage.

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

```bash
DATABASE_URL=mysql://knowledge:knowledge@127.0.0.1:3307/knowledge
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
RESEND_API_KEY=
MAGIC_LINK_FROM=ADVMFR Knowledge <onboarding@resend.dev>
```

- [ ] **Step 6 : Créer `.env`**

Copier `.env.example`, renseigner `RESEND_API_KEY` avec la clé fournie par l'utilisateur. `BETTER_AUTH_SECRET` reste vide, il est généré à la tâche 2. Le fichier est ignoré par git (`.gitignore`).

- [ ] **Step 7 : Écrire `lib/env.ts`**

```ts
import { z } from 'zod';

const schema = z.object({
  DATABASE_URL: z.string().min(1),
  BETTER_AUTH_SECRET: z.string().min(32),
  BETTER_AUTH_URL: z.string().min(1),
  RESEND_API_KEY: z.string().min(1),
  MAGIC_LINK_FROM: z.string().min(1),
});

const parsed = schema.safeParse(process.env);

if (!parsed.success) {
  const missing = parsed.error.issues.map((issue) => issue.path.join('.')).join(', ');
  throw new Error(`Variables d'environnement invalides ou manquantes : ${missing}`);
}

export const env = parsed.data;
```

- [ ] **Step 8 : Écrire `lib/db.ts`**

Un seul pool pour tout le processus : Better Auth et le script d'invitation y puisent tous les deux.

```ts
import { createPool } from 'mysql2/promise';
import { env } from '@/lib/env';

export const db = createPool(env.DATABASE_URL);
```

- [ ] **Step 9 : Vérifier que la validation refuse un environnement incomplet**

`bun` charge `.env` automatiquement (mesuré), il n'y a donc rien à passer en ligne de commande.

```bash
bun -e "import('./lib/env').then(() => console.log('CHARGÉ')).catch((e) => console.log('REFUSÉ:', e.message))"
```

Attendu : `REFUSÉ: Variables d'environnement invalides ou manquantes : BETTER_AUTH_SECRET`. La variable est présente mais vide à ce stade, et `.min(32)` la rejette : c'est la preuve que la validation mord. Elle sera renseignée à la tâche 2, et cette même commande devra alors afficher `CHARGÉ`.

- [ ] **Step 10 : Commit**

```bash
git checkout -b feature/auth-magic-link
git add docker-compose.yml .env.example .gitignore lib/env.ts lib/db.ts package.json bun.lock
git commit -m "feat: base MySQL de développement et validation des variables d'environnement"
```

---

### Task 2 : Better Auth par le CLI officiel

**Files:**
- Create: `lib/auth.ts`, `lib/auth-client.ts`, `app/api/auth/[...all]/route.ts` (générés par le CLI)
- Modify: `.env`

**Interfaces:**
- Consomme : `env` (`lib/env.ts`), `db` (`lib/db.ts`).
- Produit : `auth` exporté par `lib/auth.ts`. `auth.api.getSession({ headers })` retourne `{ user, session } | null`. Le type de session est `typeof auth.$Infer.Session`.

- [ ] **Step 1 : Générer le secret**

```bash
bunx @better-auth/cli@latest secret
```

Copier la valeur produite dans `BETTER_AUTH_SECRET` du `.env`.

- [ ] **Step 2 : Lancer l'initialisation**

```bash
bunx @better-auth/cli@latest init --package-manager bun
```

La commande est **interactive** (base de données, plugins, emplacement des fichiers). Si les prompts ne passent pas dans la session de l'agent, demander à l'utilisateur de la lancer lui-même via `! bunx @better-auth/cli@latest init --package-manager bun`.

Répondre : base **MySQL**, plugin **Magic Link**. Le CLI écrit `lib/auth.ts`, le client, le handler de route et installe ce qui manque.

- [ ] **Step 3 : Ajuster `lib/auth.ts`**

Le CLI produit une ossature générique. Elle est complétée, pas réécrite. `sendMagicLink` est laissé en place tel quel pour l'instant, il est branché à la tâche 3.

```ts
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins/magic-link';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/lib/db';
import { env } from '@/lib/env';

export const auth = betterAuth({
  appName: 'ADVMFR Knowledge',
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,
  database: db,

  // Sept jours, rafraîchis une fois par jour : une base de connaissance se
  // consulte au fil de la semaine, il n'y a pas de terminal partagé ici.
  session: {
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },

  // Le magic link est la seule porte. Aucun mot de passe n'existe.
  emailAndPassword: { enabled: false },

  advanced: {
    useSecureCookies: process.env.NODE_ENV === 'production',
  },

  // Activé explicitement : par défaut Better Auth ne limite qu'en production,
  // et l'envoi de liens est justement ce qu'il ne faut pas laisser marteler.
  rateLimit: {
    enabled: true,
    customRules: {
      '/sign-in/magic-link': { window: 300, max: 3 },
    },
  },

  plugins: [
    magicLink({
      expiresIn: 600,
      storeToken: 'hashed',
      // Seconde barrière : même un jeton valide ne peut pas créer de compte.
      // Ce n'est pas la première: voir `sendMagicLink`, tâche 3.
      disableSignUp: true,
      sendMagicLink: async ({ email, url }) => {
        console.log('magic link', email, url);
      },
    }),
    // En dernier, comme l'exige l'intégration Next : c'est ce qui pose les
    // cookies renvoyés par les Server Actions.
    nextCookies(),
  ],
});
```

- [ ] **Step 4 : Installer le CLI et l'exposer en scripts**

`bunx` ne charge **jamais** le `.env` : ni automatiquement, ni via `--env-file`, qui est avalé par bunx avant d'atteindre le paquet (mesuré). Une migration lancée par `bunx` tournerait donc sans `DATABASE_URL`. `bun run`, lui, charge `.env`. Le CLI est donc installé localement et appelé par un script.

```bash
sfw bun add -d @better-auth/cli
```

Dans `"scripts"` de `package.json` :

```json
"auth:migrate": "better-auth migrate --yes",
"auth:info": "better-auth info"
```

- [ ] **Step 5 : Créer le schéma**

```bash
bun run auth:migrate
```

- [ ] **Step 6 : Vérifier les tables**

```bash
docker exec advmfr-knowledge-mysql mysql -uknowledge -pknowledge -e "show tables" knowledge
```

Attendu : au minimum `user`, `session`, `account`, `verification`.

- [ ] **Step 7 : Vérifier la configuration**

```bash
bun run auth:info
```

Attendu : la configuration est lue sans erreur et le plugin `magic-link` apparaît.

- [ ] **Step 8 : Vérifier que l'environnement est désormais complet**

```bash
bun -e "import('./lib/env').then(() => console.log('CHARGÉ')).catch((e) => console.log('REFUSÉ:', e.message))"
```

Attendu : `CHARGÉ`, le secret généré à l'étape 1 satisfait maintenant la validation.

- [ ] **Step 9 : Commit**

```bash
git add lib/auth.ts lib/auth-client.ts app/api/auth package.json bun.lock
git commit -m "feat: instance Better Auth avec magic link sur MySQL"
```

---

### Task 3 : L'allowlist et l'envoi du lien

**Files:**
- Create: `lib/users.ts`, `lib/email.ts`, `scripts/invite.ts`
- Modify: `lib/auth.ts`, `package.json`

**Interfaces:**
- Consomme : `db` (`lib/db.ts`), `env` (`lib/env.ts`), `auth` (`lib/auth.ts`).
- Produit :
  - `isInvited(email: string): Promise<boolean>`
  - `inviteUser(email: string, name: string): Promise<void>`
  - `sendMagicLinkEmail(email: string, url: string): Promise<void>`

- [ ] **Step 1 : Écrire `lib/users.ts`**

L'allowlist est la table `user` elle-même : inviter, c'est créer la ligne ; révoquer, c'est la supprimer. Backticks autour de `user`, qui est un identifiant ambigu en MySQL.

```ts
import { randomUUID } from 'node:crypto';
import type { RowDataPacket } from 'mysql2';
import { db } from '@/lib/db';

export async function isInvited(email: string): Promise<boolean> {
  const [rows] = await db.query<RowDataPacket[]>(
    'select id from `user` where email = ? limit 1',
    [email],
  );

  return rows.length > 0;
}

export async function inviteUser(email: string, name: string): Promise<void> {
  await db.execute(
    'insert into `user` (id, name, email, emailVerified, createdAt, updatedAt) values (?, ?, ?, ?, now(), now())',
    [randomUUID(), name, email, 1],
  );
}
```

- [ ] **Step 2 : Écrire `lib/email.ts`**

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

const resend = new Resend(env.RESEND_API_KEY);

export async function sendMagicLinkEmail(email: string, url: string): Promise<void> {
  const { error } = await resend.emails.send({
    from: env.MAGIC_LINK_FROM,
    to: email,
    subject: 'Votre lien de connexion',
    text: [
      'Voici votre lien de connexion à la base de connaissance ADVMFR :',
      '',
      url,
      '',
      "Il expire dans 10 minutes et ne fonctionne qu'une fois.",
      "Si vous n'avez rien demandé, ignorez ce message.",
    ].join('\n'),
  });

  if (error) {
    throw new Error(`Envoi du magic link impossible : ${error.message}`);
  }
}
```

- [ ] **Step 3 : Brancher l'allowlist dans `lib/auth.ts`**

Remplacer le `sendMagicLink` provisoire de la tâche 2 par :

```ts
      sendMagicLink: async ({ email, url }) => {
        // Le contrôle vit ici, et pas dans `disableSignUp` : ce dernier n'est
        // évalué qu'à la vérification du lien, donc après l'envoi du mail.
        // Seul, il laisserait n'importe qui faire partir un message depuis
        // notre domaine vers une adresse arbitraire.
        if (!(await isInvited(email))) return;

        await sendMagicLinkEmail(email, url);
      },
```

Et ajouter les imports en tête de fichier :

```ts
import { sendMagicLinkEmail } from '@/lib/email';
import { isInvited } from '@/lib/users';
```

- [ ] **Step 4 : Écrire `scripts/invite.ts`**

```ts
import { db } from '@/lib/db';
import { inviteUser, isInvited } from '@/lib/users';

const [email, ...rest] = process.argv.slice(2);
const name = rest.join(' ');

if (!email) {
  console.error('Usage : bun run invite <email> [nom]');
  process.exit(1);
}

if (await isInvited(email)) {
  console.log(`${email} est déjà invité.`);
} else {
  await inviteUser(email, name || email);
  console.log(`${email} peut désormais recevoir un lien de connexion.`);
}

await db.end();
```

- [ ] **Step 5 : Ajouter le script à `package.json`**

```json
"invite": "bun run scripts/invite.ts"
```

- [ ] **Step 6 : Vérifier l'invitation**

Remplacer `<ton-email>` par l'adresse du compte Resend, la clé sans domaine vérifié n'autorise qu'elle comme destinataire.

```bash
bun run invite <ton-email> "Bastien"
bun run invite <ton-email> "Bastien"
```

Attendu : `… peut désormais recevoir un lien de connexion.` puis `… est déjà invité.`, la seconde exécution prouve l'idempotence.

- [ ] **Step 7 : Vérifier en base**

```bash
docker exec advmfr-knowledge-mysql mysql -uknowledge -pknowledge -e "select email, emailVerified from \`user\`" knowledge
```

Attendu : une ligne, `emailVerified` à 1.

- [ ] **Step 8 : Commit**

```bash
git add lib/users.ts lib/email.ts lib/auth.ts scripts/invite.ts package.json
git commit -m "feat: allowlist portée par la table user et envoi du lien via Resend"
```

---

### Task 4 : Écran de connexion

**Files:**
- Create: `app/login/page.tsx`, `app/login/login-form.tsx`, `components/sign-out-button.tsx`
- Modify: `lib/layout.shared.tsx`

**Interfaces:**
- Consomme : `authClient` (`lib/auth-client.ts`), généré à la tâche 2.
- Produit : la route `/login`, publique. Le composant `SignOutButton`.

- [ ] **Step 1 : Écrire `app/login/login-form.tsx`**

Une seule réponse quelle que soit l'adresse, y compris en cas d'erreur : l'écran ne doit jamais laisser deviner qui est invité.

```tsx
'use client';

import { useState } from 'react';
import { authClient } from '@/lib/auth-client';

export function LoginForm({ callbackURL }: { callbackURL: string }) {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);
  const [pending, setPending] = useState(false);

  if (sent) {
    return (
      <p className="text-sm text-fd-muted-foreground">
        Si cette adresse est autorisée, un lien de connexion vient de partir. Il expire dans 10
        minutes.
      </p>
    );
  }

  return (
    <form
      className="flex flex-col gap-3"
      onSubmit={async (event) => {
        event.preventDefault();
        setPending(true);
        // La réponse n'est pas lue : succès comme échec donnent le même écran.
        await authClient.signIn.magicLink({ email, callbackURL });
        setPending(false);
        setSent(true);
      }}
    >
      <input
        type="email"
        required
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        placeholder="vous@exemple.fr"
        className="rounded-lg border bg-fd-background px-3 py-2 text-sm"
      />
      <button
        type="submit"
        disabled={pending}
        className="rounded-lg bg-fd-primary px-3 py-2 text-sm font-medium text-fd-primary-foreground disabled:opacity-60"
      >
        {pending ? 'Envoi…' : 'Recevoir un lien de connexion'}
      </button>
    </form>
  );
}
```

- [ ] **Step 2 : Écrire `app/login/page.tsx`**

```tsx
import { LoginForm } from './login-form';

export default async function LoginPage(props: PageProps<'/login'>) {
  const { next } = await props.searchParams;
  const callbackURL = typeof next === 'string' && next.startsWith('/') ? next : '/docs';

  return (
    <main className="flex flex-1 flex-col justify-center px-4">
      <div className="mx-auto w-full max-w-sm">
        <h1 className="mb-2 text-xl font-bold">Base de connaissance ADVMFR</h1>
        <p className="mb-6 text-sm text-fd-muted-foreground">
          Accès réservé. Entrez votre adresse pour recevoir un lien de connexion.
        </p>
        <LoginForm callbackURL={callbackURL} />
      </div>
    </main>
  );
}
```

Le test `next.startsWith('/')` empêche une redirection ouverte : sans lui, `?next=https://ailleurs.example` enverrait l'utilisateur hors du site après connexion.

- [ ] **Step 3 : Écrire `components/sign-out-button.tsx`**

```tsx
'use client';

import { useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth-client';

export function SignOutButton() {
  const router = useRouter();

  return (
    <button
      type="button"
      className="text-sm text-fd-muted-foreground hover:text-fd-foreground"
      onClick={async () => {
        await authClient.signOut();
        router.push('/login');
      }}
    >
      Se déconnecter
    </button>
  );
}
```

- [ ] **Step 4 : Ajouter le bouton à la navigation**

Dans `lib/layout.shared.tsx`, `links` accepte un élément `{ type: 'custom' }` (`CustomItemType`, `fumadocs-ui/layouts/shared`). Les deux layouts appellent `baseOptions()`, un seul endroit suffit.

```tsx
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { SignOutButton } from '@/components/sign-out-button';
import { appName, gitConfig } from './shared';

export function baseOptions(): BaseLayoutProps {
  return {
    nav: {
      title: appName,
    },
    githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
    links: [
      {
        type: 'custom',
        secondary: true,
        children: <SignOutButton />,
      },
    ],
  };
}
```

- [ ] **Step 5 : Vérifier la compilation**

```bash
bun run types:check
```

Attendu : aucune erreur.

- [ ] **Step 6 : Commit**

```bash
git add app/login components/sign-out-button.tsx lib/layout.shared.tsx
git commit -m "feat: écran de connexion par magic link et bouton de déconnexion"
```

---

### Task 5 : Le filtrage à l'entrée et la fermeture de toutes les routes

**Files:**
- Create: `lib/session.ts`
- Modify: `lib/source.ts`, `app/(home)/page.tsx`, `app/docs/layout.tsx`, `app/docs/[[...slug]]/page.tsx`, `app/api/search/route.ts`, `app/llms.txt/route.ts`, `app/llms-full.txt/route.ts`, `app/llms.mdx/docs/[[...slug]]/route.ts`, `app/og/docs/[...slug]/route.tsx`

Une seule tâche, parce que c'est un seul changement : retirer l'export `source` de `lib/source.ts` casse ses six consommateurs au même instant. Les découper donnerait un commit qui ne compile pas.

**Interfaces:**
- Consomme : `auth` (`lib/auth.ts`).
- Produit :
  - `type Permission = 'anonymous' | 'member'`
  - `getSource(permission: Permission)` → sortie de `loader()`, mémoïsée par permission
  - `type SourcePage`, remplace `(typeof source)['$inferPage']`
  - `getCurrentSession(): Promise<Session | null>`
  - `getPermission(): Promise<Permission>`

Deux pièges vérifiés dans le code de Fumadocs, à ne pas contourner :
`update()` **mute la source en place** (`fumadocs-core/dist/loader-cKlyDdAK.js:95`), mais `toFumadocsSource()` **construit un objet neuf à chaque appel** (`fumadocs-mdx/dist/runtime/server.js:77`). Il faut donc appeler `docs.toFumadocsSource()` **à l'intérieur** de la fonction de construction, une fois par permission. L'appeler une seule fois à l'extérieur ferait effacer les pages pour tout le monde dès la première construction anonyme.

- [ ] **Step 1 : Réécrire `lib/source.ts`**

```ts
import { loader, update } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
import { defineDocs } from 'fumadocs-mdx/macro';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';

const docs = defineDocs({
  dir: 'content/docs',
  docs: {
    schema: pageSchema,
    postprocess: {
      includeProcessedMarkdown: true,
    },
  },
  meta: {
    schema: metaSchema,
  },
});

export type Permission = 'anonymous' | 'member';

/**
 * L'accès est limité à l'entrée du loader, pas au rendu : pour un anonyme, la
 * source ne contient aucune page, donc l'arbre de navigation, l'index de
 * recherche et les exports llms sont vides par construction.
 *
 * `toFumadocsSource()` rend un objet neuf à chaque appel, ce qui rend sûre la
 * mutation en place opérée par `update()`.
 */
function build(permission: Permission) {
  const filtered = update(docs.toFumadocsSource())
    .files((files) =>
      files.filter((file) => {
        // Les fichiers meta ne portent aucun contenu et structurent l'arbre.
        if (file.type === 'meta') return true;

        return permission === 'member';
      }),
    )
    .build();

  return loader({
    baseUrl: docsRoute,
    source: filtered,
    plugins: [lucideIconsPlugin()],
  });
}

// Deux permissions seulement : les deux loaders sont construits au plus une fois.
const built = new Map<Permission, ReturnType<typeof build>>();

export function getSource(permission: Permission) {
  const cached = built.get(permission);
  if (cached) return cached;

  const source = build(permission);
  built.set(permission, source);
  return source;
}

export type Source = ReturnType<typeof build>;
export type SourcePage = Source['$inferPage'];

export function getPageImageUrl(page: SourcePage) {
  const segments = [...page.slugs, 'image.png'];

  return {
    segments,
    url: '/' + [page.locale, ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
  };
}

export function getPageMarkdownUrl(page: SourcePage) {
  const segments = [...page.slugs, 'content.md'];

  return {
    segments,
    url: '/' + [page.locale, ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
  };
}

export async function getLLMText(page: SourcePage) {
  const processed = await page.data.getText('processed');

  return `# ${page.data.title} (${page.url})

${processed}`;
}
```

- [ ] **Step 2 : Écrire `lib/session.ts`**

```ts
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import type { Permission } from '@/lib/source';

export async function getCurrentSession() {
  return auth.api.getSession({ headers: await headers() });
}

export async function getPermission(): Promise<Permission> {
  return (await getCurrentSession()) ? 'member' : 'anonymous';
}
```

Aucune vérification exécutable n'est possible sur `lib/source.ts` isolément : `defineDocs` vient de `fumadocs-mdx/macro`, une macro que seul le bundler Next transforme. La preuve se fait donc au build et sous d3k, à la tâche 7.

- [ ] **Step 3 : Fermer `app/(home)/page.tsx`**

```tsx
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getCurrentSession } from '@/lib/session';

export default async function HomePage() {
  const session = await getCurrentSession();
  if (!session) redirect('/login');

  return (
    <div className="flex flex-col justify-center text-center flex-1">
      <h1 className="text-2xl font-bold mb-4">Base de connaissance ADVMFR</h1>
      <p>
        Ouvrez{' '}
        <Link href="/docs" className="font-medium underline">
          /docs
        </Link>{' '}
        pour consulter la documentation.
      </p>
    </div>
  );
}
```

- [ ] **Step 4 : Brancher `app/docs/layout.tsx` sur la permission**

L'arbre de navigation est construit à partir de la source filtrée : un anonyme qui atteindrait ce layout verrait une barre latérale vide, pas la liste des pages.

```tsx
import { getSource } from '@/lib/source';
import { getPermission } from '@/lib/session';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';

export default async function Layout({ children }: LayoutProps<'/docs'>) {
  const source = getSource(await getPermission());

  return (
    <DocsLayout tree={source.getPageTree()} {...baseOptions()}>
      {children}
    </DocsLayout>
  );
}
```

- [ ] **Step 5 : Fermer `app/docs/[[...slug]]/page.tsx`**

Trois changements : la session est exigée, la source vient de `getSource`, et `generateStaticParams` disparaît : une page qui lit la session est dynamique, la conserver ferait échouer le build.

```tsx
import { getPageImageUrl, getPageMarkdownUrl, getSource } from '@/lib/source';
import {
  DocsBody,
  DocsDescription,
  DocsPage,
  DocsTitle,
  MarkdownCopyButton,
  ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
import { notFound, redirect } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { gitConfig } from '@/lib/shared';
import { getCurrentSession, getPermission } from '@/lib/session';

export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
  const session = await getCurrentSession();
  if (!session) redirect('/login');

  const params = await props.params;
  const source = getSource(await getPermission());
  const page = source.getPage(params.slug);
  if (!page) notFound();

  const MDX = page.data.body;
  const markdownUrl = getPageMarkdownUrl(page).url;

  return (
    <DocsPage toc={page.data.toc} full={page.data.full}>
      <DocsTitle>{page.data.title}</DocsTitle>
      <DocsDescription className="mb-0">{page.data.description}</DocsDescription>
      <div className="flex flex-row gap-2 items-center border-b pb-6">
        <MarkdownCopyButton markdownUrl={markdownUrl} />
        <ViewOptionsPopover
          markdownUrl={markdownUrl}
          githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
        />
      </div>
      <DocsBody>
        <MDX
          components={getMDXComponents({
            a: createRelativeLink(source, page),
          })}
        />
      </DocsBody>
    </DocsPage>
  );
}

export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
  const params = await props.params;
  const source = getSource(await getPermission());
  const page = source.getPage(params.slug);
  if (!page) notFound();

  return {
    title: page.data.title,
    description: page.data.description,
    openGraph: {
      images: getPageImageUrl(page).url,
    },
  };
}
```

Ces cinq routes suivantes servent le contenu hors des pages. `export const revalidate = false` est retiré partout : une réponse qui dépend de la session ne peut pas être figée. `generateStaticParams` est retiré pour la même raison.

- [ ] **Step 6 : `app/api/search/route.ts`**

```ts
import { getSource } from '@/lib/source';
import { getCurrentSession } from '@/lib/session';
import { createFromSource } from 'fumadocs-core/search/server';

// L'index n'est construit que sur la source complète, et n'est atteint
// qu'après vérification de la session.
const server = createFromSource(getSource('member'));

export async function GET(request: Request) {
  const session = await getCurrentSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  return server.GET(request);
}
```

- [ ] **Step 7 : `app/llms.txt/route.ts`**

```ts
import { getSource } from '@/lib/source';
import { getCurrentSession } from '@/lib/session';
import { llms } from 'fumadocs-core/source';

export async function GET() {
  const session = await getCurrentSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  return new Response(llms(getSource('member')).index());
}
```

- [ ] **Step 8 : `app/llms-full.txt/route.ts`**

```ts
import { getLLMText, getSource } from '@/lib/source';
import { getCurrentSession } from '@/lib/session';

export async function GET() {
  const session = await getCurrentSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const scan = getSource('member').getPages().map(getLLMText);
  const scanned = await Promise.all(scan);

  return new Response(scanned.join('\n\n'));
}
```

- [ ] **Step 9 : `app/llms.mdx/docs/[[...slug]]/route.ts`**

```ts
import { getLLMText, getSource } from '@/lib/source';
import { getCurrentSession } from '@/lib/session';
import { notFound } from 'next/navigation';

export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
  const session = await getCurrentSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const { slug } = await params;
  const page = getSource('member').getPage(slug?.slice(0, -1));
  if (!page) notFound();

  return new Response(await getLLMText(page), {
    headers: {
      'Content-Type': 'text/markdown',
    },
  });
}
```

- [ ] **Step 10 : `app/og/docs/[...slug]/route.tsx`**

Cette route imprime le titre et la description dans une image : sans ce contrôle, elle publie le contenu des pages à qui devine une URL.

```tsx
import { getSource } from '@/lib/source';
import { getCurrentSession } from '@/lib/session';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
import { appName } from '@/lib/shared';

export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
  const session = await getCurrentSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const { slug } = await params;
  const page = getSource('member').getPage(slug.slice(0, -1));
  if (!page) notFound();

  return new ImageResponse(
    <DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
    {
      width: 1200,
      height: 630,
    },
  );
}
```

- [ ] **Step 11 : Vérifier la compilation**

```bash
bun run types:check
```

Attendu : aucune erreur. C'est ici que se voit un consommateur oublié : `lib/source.ts` n'exporte plus `source`, toute référence restante échoue.

- [ ] **Step 12 : Commit**

```bash
git add lib/source.ts lib/session.ts "app/(home)/page.tsx" app/docs app/api/search app/llms.txt app/llms-full.txt app/llms.mdx app/og
git commit -m "feat: filtrage du contenu à l'entrée du loader et session requise sur toutes les routes"
```

---

### Task 6 : Le périmètre et l'anti-indexation

**Files:**
- Modify: `proxy.ts`, `next.config.mjs`
- Create: `app/robots.ts`

**Interfaces:**
- Consomme : `getSessionCookie` (`better-auth/cookies`).
- Produit : redirection vers `/login` pour toute requête sans cookie de session.

- [ ] **Step 1 : Réécrire `proxy.ts`**

Le contrôle est **optimiste** : il ne lit qu'un cookie, sans requête SQL. Un cookie forgé passerait cette couche, ce sont les tâches 6 et 7 qui refusent réellement. Sans `config.matcher`, le proxy s'exécute sur **toutes** les requêtes, y compris `_next/static` : la redirection casserait alors le CSS et le JS de la page de connexion elle-même.

```ts
import { NextRequest, NextResponse } from 'next/server';
import { getSessionCookie } from 'better-auth/cookies';
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
import { docsContentRoute, docsRoute } from '@/lib/shared';

const { rewrite: rewriteDocs } = rewritePath(
  `${docsRoute}{/*path}`,
  `${docsContentRoute}{/*path}/content.md`,
);
const { rewrite: rewriteSuffix } = rewritePath(
  `${docsRoute}{/*path}.md`,
  `${docsContentRoute}{/*path}/content.md`,
);

// Les deux seules routes atteignables sans session : l'écran de connexion et
// les endpoints qui la créent.
const publicPaths = ['/login', '/api/auth'];

export default function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isPublic = publicPaths.some(
    (path) => pathname === path || pathname.startsWith(`${path}/`),
  );

  if (!isPublic && !getSessionCookie(request)) {
    const url = new URL('/login', request.nextUrl);
    if (pathname !== '/') url.searchParams.set('next', pathname);

    return NextResponse.redirect(url);
  }

  const result = rewriteSuffix(request.nextUrl.pathname);
  if (result) {
    return NextResponse.rewrite(new URL(result, request.nextUrl));
  }

  if (isMarkdownPreferred(request)) {
    const result = rewriteDocs(request.nextUrl.pathname);

    if (result) {
      return NextResponse.rewrite(new URL(result, request.nextUrl), {
        // this URL has two representations, selected by `Accept`
        headers: { Vary: 'Accept' },
      });
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt).*)'],
};
```

- [ ] **Step 2 : Écrire `app/robots.ts`**

```ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      disallow: '/',
    },
  };
}
```

- [ ] **Step 3 : Ajouter l'en-tête `X-Robots-Tag` dans `next.config.mjs`**

```js
import { createMDX } from 'fumadocs-mdx/next';

const withMDX = createMDX();

/** @type {import('next').NextConfig} */
const config = {
  reactStrictMode: true,
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }],
      },
    ];
  },
};

export default withMDX(config);
```

- [ ] **Step 4 : Vérifier la compilation**

```bash
bun run types:check
```

Attendu : aucune erreur.

- [ ] **Step 5 : Commit**

```bash
git add proxy.ts next.config.mjs app/robots.ts
git commit -m "feat: périmètre de redirection vers la connexion et anti-indexation"
```

---

### Task 7 : Vérification de bout en bout sous d3k

**Files:** aucun fichier modifié, cette tâche mesure.

**Interfaces:**
- Consomme : tout ce qui précède.
- Produit : la preuve que les sept routes sont fermées et que le flux de connexion fonctionne.

- [ ] **Step 1 : Démarrer le runtime**

```bash
d3k status --json
```

S'il ne rapporte pas `"running": true`, démarrer en tâche de fond, sans attendre la fin du processus :

```bash
d3k --no-agent --no-tui -t
```

Puis interroger `d3k status --json` jusqu'à obtenir une réponse. Ne jamais lancer `bun run dev` en parallèle. Utiliser l'`appUrl` Portless rapporté par le statut, et non un port supposé.

- [ ] **Step 2 : Mesurer l'état anonyme sur les sept routes**

Remplacer `<appUrl>` par l'URL rapportée à l'étape 1.

```bash
bun -e "const base='<appUrl>'; for (const p of ['/','/docs','/docs/test','/api/search?query=code','/llms.txt','/llms-full.txt','/llms.mdx/docs/test/content.md','/og/docs/test/image.png']) { const r = await fetch(base+p, {redirect:'manual'}); console.log(r.status, r.headers.get('location') ?? '', p); }"
```

Attendu : `307` ou `308` vers `/login…` pour chacune. Aucune réponse `200`, et surtout aucun corps contenant du contenu.

- [ ] **Step 3 : Vérifier que rien ne fuit derrière la redirection**

```bash
bun -e "const r = await fetch('<appUrl>/llms-full.txt'); const t = await r.text(); console.log(r.status, '| longueur:', t.length, '| contient Hello World:', t.includes('Hello World'));"
```

Attendu : `contient Hello World: false`. C'est la mesure qui compte : une redirection suivie qui ramènerait le contenu invaliderait tout le travail.

- [ ] **Step 4 : Ouvrir l'écran de connexion dans le navigateur managé**

```bash
d3k agent-browser --require-d3k-browser open "<appUrl>/docs"
d3k agent-browser snapshot -i
```

Attendu : l'URL est `/login?next=/docs` et le formulaire est visible. Le drapeau `--require-d3k-browser` garantit qu'aucun second navigateur n'est créé, indispensable pour que la session survive aux étapes suivantes.

- [ ] **Step 5 : Soumettre une adresse non invitée**

Utiliser les identifiants d'éléments rendus par le snapshot.

```bash
d3k agent-browser fill @e1 "inconnu@exemple.fr"
d3k agent-browser click @e2
d3k agent-browser snapshot -i
```

Attendu : le message neutre « Si cette adresse est autorisée… ». Puis vérifier qu'aucun envoi n'a eu lieu :

```bash
d3k errors --context
d3k logs --type server -n 50
```

Attendu : aucune erreur, aucune trace d'appel à Resend.

- [ ] **Step 6 : Soumettre l'adresse invitée**

Reprendre l'écran de connexion, saisir l'adresse invitée à la tâche 3, valider. Relever le lien reçu par mail, puis l'ouvrir dans le navigateur managé :

```bash
d3k agent-browser --require-d3k-browser open "<lien reçu>"
d3k agent-browser snapshot -i
```

Attendu : redirection vers `/docs`, page complète, barre latérale peuplée des deux documents.

- [ ] **Step 7 : Mesurer l'état authentifié**

Toujours dans le navigateur managé, ouvrir `/llms-full.txt` et `/api/search?query=code`.

Attendu : `200` et le contenu réel : la preuve que le filtrage discrimine bien la permission, et ne casse pas l'accès légitime.

- [ ] **Step 8 : Se déconnecter et re-mesurer**

Cliquer sur « Se déconnecter », puis rejouer l'étape 2.

Attendu : le même tableau de redirections qu'à l'état anonyme. Une seule route restée `200` signifie une session non révoquée.

- [ ] **Step 9 : Vérifier le build de production**

```bash
bun run build
```

Attendu : le build passe. Une erreur mentionnant le rendu statique d'une route signifie qu'un `generateStaticParams` ou un `export const revalidate` a survécu quelque part.

- [ ] **Step 10 : Écrire les notes de déploiement O2switch**

Le déploiement est hors périmètre, sa documentation ne l'est pas. Créer `docs/deploiement-o2switch.md` :

```markdown
# Déploiement sur O2switch (cPanel)

Non exécuté à ce jour. Ce document liste ce qui a été établi pendant le
développement ; les deux premiers points sont à confirmer auprès de l'hébergeur
avant toute tentative.

## À confirmer d'abord

1. **Version de Node.** Next 16 exige Node ≥ 20.9. Le sélecteur Node de cPanel
   doit proposer au moins cette version, sinon l'hébergement ne convient pas.
2. **Accès SSH.** Nécessaire pour la migration du schéma et pour inviter des
   utilisateurs. Sans lui, il faut une autre voie (phpMyAdmin pour les tables,
   insertion manuelle pour les comptes).

## Base de données

Créer une base et un utilisateur MySQL dans cPanel (« Bases de données MySQL »),
puis composer `DATABASE_URL` :
`mysql://<user>:<motdepasse>@localhost:3306/<base>`

Le schéma se crée avec `better-auth migrate`, la même commande qu'en
développement. Elle lit `DATABASE_URL` depuis l'environnement du processus.

## Application

`next.config.mjs` doit produire une sortie autonome :

    const config = { output: 'standalone', /* … */ }

Passenger démarre un fichier d'entrée : `.next/standalone/server.js`. Les
dossiers `.next/static` et `public` ne sont pas inclus dans `standalone` et
doivent être copiés à côté du serveur.

Dans « Setup Node.js App » : renseigner la racine de l'application, le fichier de
démarrage, le mode `production`, et les variables d'environnement
(`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `RESEND_API_KEY`,
`MAGIC_LINK_FROM`).

`BETTER_AUTH_URL` doit être l'origine publique en HTTPS : c'est elle qui construit
les liens de connexion envoyés par mail.

## Resend

Avant la mise en service, vérifier le domaine d'envoi chez Resend (SPF/DKIM) et
remplacer `onboarding@resend.dev` dans `MAGIC_LINK_FROM`. Sans domaine vérifié,
Resend n'accepte qu'un seul destinataire : le titulaire du compte.
```

- [ ] **Step 11 : Consigner le résultat et fusionner**

```bash
bun run lint
git add docs/deploiement-o2switch.md
git commit -m "docs: notes de déploiement O2switch"
git checkout dev
git merge feature/auth-magic-link --ff-only
git branch -d feature/auth-magic-link
```

`main` n'est pas touché : la mise en production est une décision de l'utilisateur.
