# seoinsight-dashboard

Mini dashboard SEO. Un visiteur d'un de nos sites tape son domaine, voit un
verdict partiel gratuit, laisse son email pour le rapport complet, et le
consulte ici. Déployé par Dokploy sur **seo.seoinsight.app**.

L'audit lui-même n'est pas ici : c'est le CLI Rust
[`nextjs-seo-prerender`](https://github.com/bastienallain/nextjs-seo-prerender-cli),
compilé dans l'image Docker et lancé en sous-processus par le worker.

---

## Règles de travail — non négociables

1. **Jamais écrire à la main l'ossature d'un outil.** Toujours son CLI officiel,
   avec **bun** : `bunx --bun create-next-app@latest`, `bunx drizzle-kit generate`,
   `bunx shadcn@latest add` si un jour on en veut. Un `package.json` édité à la
   main ou une arborescence inventée produit une base non idiomatique qui diverge
   des versions officielles.
2. **Toute dépendance passe par `sfw bun add <pkg>`** (Socket Firewall). Jamais
   `npm`, `pnpm` ou `yarn`. `sfw` bloque les postinstalls malveillants en amont.
3. **Simple et light. Pas d'overengineering.** Ce dépôt doit rester lisible en une
   séance. Chaque service, chaque dépendance, chaque abstraction se justifie ou
   ne rentre pas.
4. Commentaires en anglais, sobres, qui disent le **pourquoi** — jamais ce que le
   code dit déjà. Le dépôt `nextjs-seo-prerender` est l'étalon du ton.

## Architecture, et pourquoi

```
elevaseo.com ┐
seoinsight.app ├─ POST /api/audit ─▶ dashboard (Next.js) ─▶ Postgres
seo.…app      ┘   Origin + Turnstile                         ▲ file de jobs
                                                             │
                                              worker (même image) ─▶ binaire d'audit
```

- **Pas de Redis, pas de BullMQ.** La file vit dans Postgres
  (`SELECT … FOR UPDATE SKIP LOCKED`), qui est là de toute façon pour better-auth.
  Dix audits en parallèle, les suivants attendent. Si le volume l'exige un jour,
  BullMQ s'ajoutera sans rien casser — pas avant.
- **Le worker est la même image que l'app**, avec une commande différente. Une
  image à construire, un jeu de dépendances, un schéma partagé.
- **Le binaire est compilé dans l'image** (étape multi-stage qui clone le dépôt du
  CLI à `ARG HSC_REF`). Quand une release publiera les binaires musl, remplacer le
  clone par un téléchargement : plus rapide, plus reproductible.
- **Les rapports vivent dans Postgres, pas dans un cache à TTL.** Le dashboard doit
  pouvoir les montrer des mois plus tard : c'est la contrepartie de l'email capté.

## Invariants de sécurité

Ils ne se négocient pas, et un changement qui les touche mérite d'être discuté :

- **La clé d'audit ne quitte jamais le serveur.** Une clé envoyée au navigateur est
  publique par construction. Le navigateur parle à cette app, jamais à l'audit.
- **`Origin` dans l'allowlist des trois domaines, sinon 403**, avant tout travail.
  Puis Turnstile, puis la limite par IP, puis la validation de cible.
- **Le teaser ne fuite rien** : verdict, compteurs, et les deux types de findings
  les plus fréquents. Jamais une URL, jamais un détail, jamais le rapport.
- **Le rapport complet n'a aucune route API.** Il est lu côté serveur dans la page
  du dashboard, après vérification de la session *et* de la propriété du job.
- **La barrière anti-SSRF est `egress-rules.sh`** (chaîne `DOCKER-USER`), pas le
  contrôle TypeScript. Le binaire suit les redirections : une cible publique peut
  répondre 302 vers `169.254.169.254`, et un DNS à TTL court peut changer entre
  notre résolution et la sienne. `lib/public-target.ts` donne un 400 lisible au
  visiteur ; c'est du confort, pas une barrière. **Sans `egress-rules.sh`, le
  formulaire public est un moyen de lire les métadonnées du serveur.**

## Le CLI d'audit — ce qu'il faut savoir

Son contrat vit dans `docs/contract.md` du dépôt `nextjs-seo-prerender` :

- **§1** : les drapeaux (`--json`, `--sample N`, `--all`, `--urls`, `--allow-dynamic`,
  `--no-verify`). Le positionnel porte l'origine ; un chemin sert d'indice de
  découverte de sitemap, jamais de périmètre.
- **§2** : les codes de sortie. `0` et `1` rendent un rapport complet (`1` = des
  problèmes bloquants) ; `2` rend un document fatal — **toujours du JSON valide** ;
  `130` = interrompu.
- **§4** : le schéma du document JSON. `summary.blocking`, `warnings`, `exit_code`,
  `pages[]`, `pages[].findings[]` avec `kind` (slug stable — matcher là-dessus,
  jamais sur le message), `severity`, `message`, `detail[]`.
- **§6** : les 18 `kind` de findings et leur sévérité.

## Commandes

```bash
bun install
bun run ci                      # typecheck + lint + code mort + tests — doit passer
bun run build                   # doit passer
bunx drizzle-kit generate       # après toute modification de db/schema.ts
sfw bun add <pkg>               # toute nouvelle dépendance
```

`bun run format` (oxfmt) existe mais n'est **pas** dans `ci` : le dépôt n'a jamais
été formaté par un outil, et l'y passer réécrirait 24 fichiers sur 42. À faire un
jour dans un commit à part, pas au détour d'un autre.

Quelques règles d'Oxlint sont désactivées dans `.oxlintrc.json`, et chacune pour
une raison : `no-await-in-loop` (les boucles de polling du worker et du
formulaire attendent séquentiellement, c'est le principe), `no-array-sort`
(`toSorted` dépasse la cible TypeScript), `react-in-jsx-scope` et
`import/no-unassigned-import` (faux positifs du transform JSX automatique et de
l'import de `globals.css`), `react-perf/*` (rien n'est mémoïsé ici, et rien ne
gagnerait à l'être).

## Développement — d3k, jamais Docker

**On ne développe pas le dashboard dans un conteneur.** Docker sert à construire
l'image et à la déployer, pas à écrire du code : rebuild à chaque modification,
hot reload perdu, logs éparpillés. Le confort de développement passe par
[d3k](https://github.com/vercel-labs/dev3000), qui lance le serveur de dev
derrière une URL stable, possède son propre profil Chrome (les sessions restent
ouvertes d'une fois sur l'autre) et rassemble serveur, console navigateur,
réseau, interactions et captures dans une seule frise chronologique.

```bash
docker compose -f compose.dev.yaml up -d   # la base seule — c'est tout ce qui est en conteneur
bunx drizzle-kit migrate                   # une fois, sur une base neuve
d3k status --json                          # une instance tourne déjà ? on la réutilise
d3k --no-agent --no-tui -t                 # sinon : dev server + Chrome supervisé, hot reload
bun run worker                             # le worker, dans un terminal à part
```

Règles qui vont avec :

- **Jamais `bun run dev` à côté de d3k**, ni un deuxième d3k : `d3k status --json`
  d'abord, on réutilise ce qui tourne.
- Pour enquêter sur un bug : `d3k errors --context`, puis `d3k logs -n 200`,
  plutôt que de gratter des logs à la main. Le navigateur et le serveur sont dans
  la même timeline, c'est tout l'intérêt.
- Ne jamais remplacer le Chrome supervisé par Playwright ou un autre profil : les
  flux d'authentification (lien magique, OAuth) cassent, et le profil stable est
  précisément ce qui garde la session ouverte entre deux essais.
- En local, le worker pointe sur le binaire compilé du dépôt voisin :
  `HSC_BIN=../nextjs-seo-prerender/target/release/nextjs-seo-prerender`. Inutile de
  reconstruire l'image pour tester un audit.
- `compose.dev.yaml` publie Postgres sur **5433**, pas 5432 : un Postgres local
  occupe presque toujours le port standard, et la collision ne dit pas son nom.
  L'app, le worker et `drizzle-kit` tournent sur l'hôte, donc `DATABASE_URL` vise
  `localhost:5433` — le nom de service `postgres` ne résout que dans le réseau du
  déploiement.
- **Une variable exportée dans le shell l'emporte sur les fichiers `.env`**, pour
  Next comme pour bun. Un `DATABASE_URL` traînant dans le terminal d'où d3k est
  lancé ressort en `ENOTFOUND postgres` sur `POST /api/audit`, fichier corrigé ou
  non. `assertEnv` le dit maintenant au démarrage, pour toutes les variables du
  schéma — mais il faut encore vider l'export : `set -e DATABASE_URL`.
- **`BETTER_AUTH_URL` est ignorée en développement**, et il n'y a rien à mettre à
  sa place : better-auth lit l'hôte sur la requête, donc le lien magique pointe
  toujours sur le serveur qui vient de l'émettre, quel que soit le port choisi
  par d3k. La variable ne sert qu'en production, où le domaine est fixe. C'est
  une origine (`https://seo.seoinsight.app`), jamais une page — un `/auth` à la
  fin donnerait `…/auth/api/auth/magic-link/verify`.
- **Le lien magique est écrit dans le log serveur en développement**, donc dans la
  frise d3k : `d3k logs --type server | grep "magic link"`. L'envoi Resend est
  quand même tenté — c'est le chemin qui part en production — mais son échec ne
  bloque plus rien ici, où le domaine expéditeur n'est pas vérifié.

`docker compose up --build` reste utile pour vérifier que l'image se construit et
que les trois services se parlent — avant un déploiement, pas pendant l'écriture.

## Reste à faire

- [ ] Brancher le formulaire sur les trois sites (exemple `fetch` dans le README)
- [ ] DNS `seo.seoinsight.app` → Dokploy, enregistrement proxifié Cloudflare
- [ ] Clés Turnstile (site + secret) et domaine vérifié chez Resend
- [ ] Lancer `egress-rules.sh` sur l'hôte **avant** d'ouvrir le formulaire au public
- [x] Migrations au déploiement : le worker lance `db/migrate.ts` avant de
      consommer, et il est le seul à le faire. En local, `drizzle-kit` reste
      l'outil — les devDependencies sont là.
- [ ] Remplacer le clone Rust par le téléchargement d'une release quand elle existe

## Contexte utile

- Le service HTTP autonome qui exposait l'audit sur `hsc.elevaseo.com` (clés par
  site, nginx, systemd) **a été retiré** : l'audit vit dans cette image maintenant.
  Son histoire est dans `nextjs-seo-prerender`, dossier `selfhost/`.
- Cloudflare coupe une requête d'origine vers 100 s. Un audit long doit donc être
  asynchrone — c'est la raison d'être de la file, pas une élégance.
