# Rendre la knowledge base privée, design

Date : 2026-08-18
Statut : **abandonné le 2026-08-18 au profit d'une protection au niveau serveur**

> ## Décision révisée
>
> Ce design n'est pas mis en œuvre. Question posée en cours d'exécution : un
> `.htaccess` n'aurait-il pas suffi ? Il suffit, et il fait même une chose que ce
> design ne fait pas : il bloque **avant** Passenger, donc avant Next.js, ce qui
> rend structurellement impossible d'oublier une route. Le magic link n'apportait
> en plus que l'identité du lecteur, une déconnexion et la possibilité d'ouvrir
> plus tard une partie du contenu au public. Aucun des trois n'est un besoin
> actuel, et chacun coûtait une base de données, un service d'envoi d'emails et
> un point de défaillance unique dans la délivrabilité d'un mail.
>
> Dispositif retenu : **Confidentialité du répertoire** de cPanel, un identifiant
> par personne. Voir `docs/acces-prive-htaccess.md`.
>
> Ce qui a été construit avant la bascule dort sur la branche
> `feature/auth-magic-link` : la base MySQL de développement avec la validation
> Zod des variables, et l'instance Better Auth générée par son CLI officiel. Rien
> n'a été fusionné dans `dev`. Le plan d'implémentation correspondant reste dans
> `docs/superpowers/plans/2026-08-18-knowledge-base-privee.md`.
>
> Le reste de ce document décrit le design abandonné et se lit comme tel.
Projet : `advmfr-knowledge` (Fumadocs 16.14.4 / Next.js 16.3 / React 19)

## Problème

La base de connaissance est aujourd'hui entièrement publique. Sept routes servent
le contenu : les pages, la recherche, les exports markdown, les images OG, et
aucune ne demande quoi que ce soit à son visiteur. On veut qu'elle soit privée :
aucun contenu accessible sans compte.

## Décision

Auth par **magic link** (Better Auth), utilisateurs stockés dans **MySQL cPanel**,
envoi par **Resend**, et filtrage du contenu **à l'entrée du loader Fumadocs**
selon le guide [Access Control](https://fumadocs.dev/docs/guides/access-control).

Un seul niveau d'accès : connecté ou rien. Pas de rôles, pas de frontmatter
`permission`, la factory décrite plus bas permettra de les ajouter plus tard
sans refonte.

### Alternatives écartées

**Périmètre seul** (proxy + contrôle de session dans chaque route, sans toucher
au loader). Moins de code, mais toute route ajoutée plus tard et oubliée dans le
gate republie le contenu. Le filtrage à l'entrée supprime cette classe d'erreur
entière.

**Filtrage à l'entrée seul**, sans proxy : un anonyme reçoit des 404 partout et
ne voit jamais d'écran de connexion. Rien à gagner.

**Table `allowed_email` dédiée.** L'allowlist serait alors une seconde source de
vérité pouvant contredire la table `user`. Voir « Allowlist » ci-dessous.

## Architecture, trois couches

### 1. Le périmètre : `proxy.ts`

Le fichier existe déjà et réécrit les chemins markdown. Il gagne un contrôle
**optimiste** du cookie de session (`getSessionCookie`, aucune requête SQL) qui
redirige vers `/login` toute requête non authentifiée. Restent ouverts
`/login`, `/api/auth/*` et les assets Next.

Cette couche est une commodité d'ergonomie, **pas une frontière de sécurité** :
la documentation Better Auth le dit explicitement, un cookie présent ne prouve
pas qu'une session est valide. Les couches 2 et 3 sont les vraies barrières.

### 2. La validation réelle : chaque route serveur

Chaque route lit la session via `auth.api.getSession({ headers: await headers() })`
et refuse sans elle. Inventaire exhaustif des routes à fermer :

| Route | Ce qu'elle expose aujourd'hui |
|---|---|
| `/` | page d'accueil |
| `/docs/[[...slug]]` | toutes les pages |
| `/api/search` | index de recherche complet |
| `/llms.txt` | liste des pages |
| `/llms-full.txt` | texte intégral de toute la base |
| `/llms.mdx/docs/[[...slug]]` | markdown source de chaque page |
| `/og/docs/[...slug]` | titre et description imprimés dans une image |

La dernière est la moins évidente et fuit dès aujourd'hui : une image OG est une
requête publique sans referer qui rend le titre et la description d'une page.

S'y ajoutent un `robots.txt` interdisant tout et un en-tête `X-Robots-Tag: noindex`.

### 3. Le filtrage à l'entrée : `lib/source.ts`

`source` devient une factory, conformément au guide Fumadocs :

```ts
export function getSource(session: Session | null)
```

Sans session, le `Source` passé à `loader()` ne contient **aucun fichier** : le
contenu n'est pas caché, il n'existe pas. L'arbre de navigation, l'index de
recherche et les exports llms retournent du vide par construction, même si un
contrôle de la couche 2 venait à manquer quelque part.

Deux résultats possibles seulement (anonyme, authentifié) : ils sont mémoïsés en
module, le guide notant lui-même que la factory doit être cachée.

Conséquence : tous les consommateurs de `source` deviennent asynchrones, et
`generateStaticParams` disparaît de `app/docs/[[...slug]]/page.tsx`, une page qui
lit la session est dynamique par nature.

## Authentification

### Génération : le CLI officiel

L'ossature n'est pas écrite à la main. Séquence :

```bash
bunx @better-auth/cli@latest init --package-manager bun   # scaffold + deps
bunx @better-auth/cli@latest secret                       # BETTER_AUTH_SECRET
bunx @better-auth/cli@latest migrate --yes                # tables MySQL
```

`init` est interactif (base de données, plugins) : si le prompt ne passe pas dans
la session agent, il est lancé par l'utilisateur via `! bunx …`.

Toute dépendance ajoutée passe par `sfw` (`sfw bun add resend`).

### Configuration

`lib/auth.ts`, produit par `init` puis ajusté :

- adaptateur intégré sur un pool **`mysql2`** (`DATABASE_URL` cPanel) ;
- plugin `magicLink` : `expiresIn` 600 s, `storeToken: "hashed"`,
  `disableSignUp: true` ;
- `nextCookies()` **en dernier** dans `plugins`, comme l'exige l'intégration Next ;
- session : `expiresIn` 7 jours, `updateAge` 1 jour ;
- `rateLimit` activé sur l'envoi de liens ;
- `advanced.useSecureCookies` en production.

Handler : `app/api/auth/[...all]/route.ts`.

### Allowlist

**L'allowlist est la table `user` elle-même.** Inviter quelqu'un, c'est créer sa
ligne ; le révoquer, c'est la supprimer. Aucune table parallèle susceptible de la
contredire.

Le contrôle vit dans `sendMagicLink`, pas dans `disableSignUp`. Raison mesurée
dans le code du plugin (`better-auth/dist/plugins/magic-link/index.mjs:159`) :
`disableSignUp` est évalué **à la vérification du lien**, donc après l'envoi du
mail. Seul, il laisserait n'importe qui déclencher un email depuis notre domaine
vers une adresse arbitraire, et l'inconnu recevrait un lien qui échoue au clic.

`sendMagicLink` cherche donc l'utilisateur : s'il existe, Resend envoie ; sinon,
silence. L'endpoint répond 200 dans les deux cas: aucun oracle d'énumération,
aucun mail parasite. `disableSignUp: true` reste posé comme seconde barrière.

Un jeton est écrit en base pour une adresse non autorisée. Sans destinataire et
sans création de compte possible, il est inerte.

### Écrans

`/login` : un champ email, un bouton, et une seule réponse quelle que soit
l'adresse : « si cette adresse est autorisée, un lien vient de partir ». La page
est publique ; elle ne révèle rien.

Après clic sur le lien, Better Auth pose la session et redirige vers `/docs`.

### Invitations

Script `scripts/invite.ts` (`bun run invite <email> [nom]`) qui insère la ligne
`user`. Pas d'écran d'administration en v1 : le besoin n'existe pas encore, et la
factory `getSource` rend son ajout ultérieur peu coûteux.

## Environnement

`lib/env.ts` valide au démarrage avec **Zod**, conformément aux règles du projet.
Un démarrage sans `RESEND_API_KEY` doit échouer bruyamment, pas envoyer des mails
dans le vide.

| Variable | Rôle |
|---|---|
| `DATABASE_URL` | MySQL cPanel |
| `BETTER_AUTH_SECRET` | généré par `better-auth secret` |
| `BETTER_AUTH_URL` | origine publique |
| `RESEND_API_KEY` | envoi du magic link |
| `MAGIC_LINK_FROM` | expéditeur, sur un domaine vérifié DKIM chez Resend |

## Vérification

Le projet n'a aucun harnais de test et n'en gagne pas un pour cette tâche. La
validation est faite de bout en bout avec **d3k**, dans son navigateur managé
(profil stable, indispensable pour un flux d'authentification) :

1. anonyme : les sept routes de l'inventaire redirigent ou refusent, et
   `/llms-full.txt` comme `/api/search` ne rendent aucun contenu ;
2. adresse non invitée : réponse neutre, aucun mail envoyé (vérifié côté Resend) ;
3. adresse invitée : lien reçu, session posée, `/docs` complet ;
4. après déconnexion : retour à l'état 1.

Les erreurs serveur et navigateur sont lues via `d3k errors --context`.

## Hors périmètre

- **Déploiement cPanel / Passenger.** Décidé hors scope : le code est livré et
  vérifié en local, la procédure O2switch est documentée mais pas exécutée.
- Rôles, permissions par page, écran d'administration.
- Migration du contenu existant : les deux fichiers MDX du scaffold restent tels quels.

## Risques ouverts

1. **Version de Node chez O2switch.** Next 16 exige Node ≥ 20.9. À confirmer
   avant tout déploiement ; c'est le risque qui peut invalider l'hébergement.
2. **Accès SSH.** Les migrations et le script d'invitation en ont besoin. Sans
   lui, il faudra une autre voie pour créer les tables et les comptes.
3. **DKIM Resend.** Le magic link est le point de défaillance unique de tout
   l'accès : le domaine d'envoi doit être vérifié avant la mise en service.
4. **Git.** `advmfr-knowledge` n'est pas un dépôt : il se trouve à l'intérieur de
   `~/Github`, lui-même dépôt sur `main`. Aucun commit n'est possible sans
   trancher ce point d'abord.
