# Autorité Google Search Console au niveau du site

**Date** : 2026-07-26
**Branche** : `feature/gsc-site-authority`
**Statut** : validé, prêt pour le plan d'implémentation

## Le problème

Trois chemins de résolution du jeton GSC coexistent, avec trois règles différentes :

| Chemin | Règle | Appelé par |
|---|---|---|
| `getOrgOwnerGscToken(orgId)` | owner **puis** admin, owner préféré | statut, mapping, sync UI, import, readiness prompt |
| `resolveWorkerGscToken(siteId)` | site → **owner strict**, aucun repli admin | broker worker (`/api/worker/gsc-token`) |
| `getGscAccessToken()` | session courante | **personne** — code mort |

Conséquence en production : une org dont l'admin a connecté Google et pas l'owner
affiche « connecté », laisse mapper les propriétés, laisse lancer un sync manuel —
et son worker échoue silencieusement avec `[gsc-broker] owner sans compte Google lié`.
Les deux chemins ne peuvent pas être d'accord, parce qu'ils n'implémentent pas la
même règle.

Le modèle org-scoped est faux dès le départ pour le cas agence : le propriétaire du
compte SEO Insight n'est pas forcément la personne qui a les accès Search Console
du client. C'est le consultant qui gère le site qui les a.

## La décision

L'autorité GSC devient un attribut **du site**, pas de l'organisation :
`sites.gsc_authorized_user_id`. Toute personne ayant accès au site peut y rattacher
son propre compte Google.

### Approche retenue : autorité revalidée à l'usage

La colonne stocke *qui*, mais ne fait pas foi à elle seule. Un point de résolution
unique revérifie à chaque appel que cet utilisateur est toujours membre de l'org du
site et a toujours accès à ce site, avant de demander le jeton à Better Auth.

**Pourquoi pas un nettoyage par hooks.** Better Auth 1.6.23 expose
`organizationHooks.beforeRemoveMember` / `afterRemoveMember`, mais **uniquement** sur
`removeMember` (`node_modules/better-auth/dist/plugins/organization/routes/crud-members.mjs:193`
et `:204`). La route `/organization/leave` appelle `adapter.deleteMember` en direct
(`:409`) sans passer par eux — et on l'utilise vraiment (`src/components/team-management.tsx:1008`).
Un membre qui part de lui-même resterait l'autorité GSC du site. Une correction par
hooks ferait donc reposer la sécurité sur l'exhaustivité d'une liste de chemins,
exactement le type d'inventaire qui se périme au prochain `bun update`.

La revalidation à l'usage est fail-closed par construction, quel que soit le chemin
par lequel l'accès a disparu. Les hooks restent utiles comme **confort d'affichage**
(marquer le site « à reconnecter » dès le retrait plutôt qu'à la prochaine tentative),
mais la correction n'en dépend pas.

## Sécurité : l'invariant headerless

`resolveUserId` (`node_modules/better-auth/dist/api/routes/account.mjs:236`) :

```js
const session = await getSessionFromCtx(ctx, { disableCookieCache: isStateful(ctx) });
if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED");
const resolvedUserId = session?.user?.id || userId;
```

Deux régimes, séparés par la présence de `headers` :

- **Appel HTTP** : session obligatoire, et **la session gagne toujours** sur le
  `userId` du body. Un attaquant ne peut pas voler le jeton d'autrui en postant son
  `userId` sur `/get-access-token`.
- **Appel `auth.api` sans headers** : le serveur est réputé de confiance et peut
  nommer n'importe quel `userId`.

**INVARIANT — `resolveSiteGscToken` appelle `getAccessToken` SANS `headers`.**
Passer les headers (réflexe naturel dans une Server Action) ferait écraser
silencieusement l'autorité par la session de l'appelant : on résoudrait le jeton du
mauvais utilisateur, sans aucune erreur, avec des données venant du mauvais compte
Google. Un test verrouille cet invariant.

**Corollaire : `resolveSiteGscToken` EST la frontière d'autorisation.** Better Auth
ne vérifie plus rien pour nous sur ce chemin, par conception. C'est pourquoi
`userCanAccessSite` vit *dedans* et pas aux points d'appel — un point d'appel qui
oublie, et il n'y a plus de filet.

### Réserves consignées

`getValidAccessToken` **rafraîchit et persiste** le jeton quand il approche de
l'expiration (`account.mjs:275-290`). Un « simple sync » écrit donc dans le compte de
l'utilisateur autorité. Sous impersonation, un opérateur qui lance un sync manuel
déclenche cette écriture sur le compte du client, sans passer par la garde
d'impersonation puisque l'appel est headerless. Comportement normal d'OAuth, mais on
journalise l'autorité et l'opérateur.

Prérequis déjà en place : `encryptOAuthTokens: true` (`src/lib/auth.ts:119`) et
`accountLinking.allowDifferentEmails: true` (`:131`) — ce dernier est indispensable,
tout le design repose sur le fait que le compte Google du consultant n'est pas son
compte de connexion.

## Schéma

Migration `0070` :

```sql
ALTER TABLE sites ADD COLUMN gsc_authorized_user_id text
  REFERENCES "user"(id) ON DELETE SET NULL;
CREATE INDEX sites_gsc_authorized_user_idx ON sites (gsc_authorized_user_id);
```

`ON DELETE SET NULL` et non `CASCADE` : supprimer un compte utilisateur ne doit pas
supprimer le site.

### L'autorité est un utilisateur ET un compte Google (migration `0071`)

**Décision prise pendant l'implémentation, après la revue de la tâche 3.** Le premier
jet ne stockait que l'utilisateur. La revue a montré que ça ne pouvait pas tenir :

- `deriveSiteGscState` choisissait un compte Google de l'autorité au hasard
  (`.limit(1)` sans `ORDER BY`) ;
- Better Auth en choisit **un autre, indépendamment**, au moment d'émettre le jeton
  (`account.mjs:270` — `find(acc => acc.providerId === providerId)`, sans `accountId`).

Deux tirages arbitraires non corrélés : un site pouvait se dériver `ok` puis recevoir
le jeton d'un compte sans droit sur la propriété — un 403 Google opaque au lieu d'une
raison typée, exactement ce que le module promet d'empêcher.

Un simple ordre déterministe ne suffisait pas. Un consultant détenant les Search
Console de deux clients verrait ses deux sites résolus sur le **même** compte Google —
le cas agence, celui qui motive toute la refonte, resterait cassé. Et ce n'est pas
théorique : `auth.ts:131` active `accountLinking.allowDifferentEmails` précisément
pour ça.

```sql
ALTER TABLE sites ADD COLUMN gsc_authorized_account_id text;
```

Deux colonnes, une fonction chacune :

| Colonne | Rôle |
|---|---|
| `gsc_authorized_user_id` | qui — revalidation de l'accès, affichage « connecté par X » |
| `gsc_authorized_account_id` | quel compte Google est interrogé |

`gsc_authorized_account_id` porte `account.account_id` (l'identifiant Google), pas
`account.id` : c'est ce que `getAccessToken` attend dans `body.accountId`. Pas de
contrainte de clé étrangère pour cette raison — la disparition du compte se traduit
par un échec de résolution typé (`no-google-account`), pas par une erreur de
contrainte.

**Corollaire d'ergonomie : aucun sélecteur de compte dans l'interface.** L'utilisateur
choisit une *propriété*, pas un compte. Le dropdown agrège les propriétés vérifiées de
tous ses comptes Google porteurs du scope, et le mapping enregistre le compte qui voit
réellement cette propriété. Le modèle correct se déduit du geste naturel.

**Le mapping de propriété survit à la perte d'autorité.** `gsc_property` reste
renseignée quand `gsc_authorized_user_id` retombe à `NULL`. Ce n'est pas de
l'incohérence : c'est ce qui permet à la reprise en main d'être un clic — le site sait
toujours quelle propriété il regarde, il lui manque quelqu'un pour la lire.

## Résolution du jeton

Nouveau module `src/lib/gsc/site-token.ts`, qui remplace les trois chemins actuels :

```ts
export type SiteGscFailure =
  | "no-property"        // aucune propriété mappée
  | "no-authority"       // personne n'a branché son compte
  | "authority-lost"     // l'autorité n'a plus accès au site
  | "no-google-account"  // plus de compte Google lié
  | "missing-scope"      // compte lié, consentement Search Console non coché
  | "token-refused";     // Google a refusé le rafraîchissement

export async function resolveSiteGscToken(siteId: string): Promise<
  | {
      ok: true;
      accessToken: string;
      property: string;
      authorityUserId: string;
      authorityAccountId: string;
    }
  | { ok: false; reason: SiteGscFailure }
>;
```

Chaîne : site → propriété + autorité → **revalidation de l'accès** → compte Google +
scope → `auth.api.getAccessToken({ body: { providerId: "google", userId, accountId } })`.

`accountId` est obligatoire, pas optionnel : sans lui Better Auth refait sa propre
sélection arbitraire et le jeton peut venir d'un autre compte que celui qu'on a
validé.

Le retour typé plutôt qu'un `null` est le pivot du design : il rend l'état de santé
possible sans écrire un second chemin de diagnostic à côté. Le worker journalise la
raison, l'UI la traduit en phrase, et les deux ne peuvent pas diverger puisqu'ils
lisent la même sortie. C'est exactement ce qui a dérivé jusqu'ici.

### `userCanAccessSite(userId, siteId)`

Helper à extraire dans `src/lib/sites/access.ts` : membre de l'org du site **et**
(rôle `owner`/`admin` **ou** ligne dans `site_access`).

Il faut une variante à `userId` explicite parce que le worker tourne hors session —
`currentUserCanAccessSite` lit `headers()`. L'existante est réécrite pour déléguer à
la nouvelle : une seule règle d'accès, pas deux formulations qui divergeront.

## Gates

| Action | Aujourd'hui | Après |
|---|---|---|
| Lier son compte Google | owner/admin (`canLinkGsc`) | tout utilisateur connecté — c'est son propre compte |
| Mapper une propriété sur un site | owner/admin (`canManageGsc`) | `currentUserCanAccessSite(siteId)` |
| Lancer un sync manuel | owner/admin | `currentUserCanAccessSite(siteId)` |
| Voir l'état GSC d'un site | owner/admin | `currentUserCanAccessSite(siteId)` |

`canManageGsc`, `isGscOwner`, `orgHasGoogleAccountMissingScope` et
`getOrgOwnerGscToken` disparaissent.

`canLinkGsc` **reste**, mais uniquement parce que SE Ranking et les connecteurs s'en
servent comme gate owner/admin (`connectors/core.ts:74,103`,
`seranking/integration-core.ts:71,106`). Elle est mal nommée pour cet usage : on la
renomme `canManageOrgConnectors`.

### Démantèlement de `gsc/owner.ts`

Le module a accumulé des choses qui n'ont rien de GSC. Le dissoudre sans les
replacer casserait la facturation et le MCP — destination export par export :

| Export | Destination |
|---|---|
| `requestHeaders` | `src/lib/auth/request-headers.ts` — **pas** du GSC, utilisé par `mcp/keys-actions.ts:15` et `billing/pack-actions.ts:19` |
| `canLinkGsc` | renommé `canManageOrgConnectors`, vers `src/lib/connectors/access.ts` |
| ré-export de `hasWebmastersScope`, `WEBMASTERS_READONLY_SCOPE` | supprimé — les appelants importent `./scope` directement |
| `canManageGsc`, `isGscOwner`, `getOrgOwnerGscToken`, `orgHasGoogleAccountMissingScope` | supprimés |

Trois fichiers de test stubbent `requestHeaders` depuis `@/lib/gsc/owner` et suivent
le déplacement : `gsc/actions.test.ts:27`, `mcp/keys-actions.test.ts:24`,
`billing/pack.test.ts:105`.

## UI

La **carte de connexion** devient « ton compte Google » — lier/délier, état du scope,
pour l'utilisateur courant.

La **carte des propriétés** devient le centre : une ligne par site accessible, avec la
propriété mappée, **qui l'a branchée**, et l'état dérivé. Le dropdown des propriétés
liste celles de *ton* compte, pas de celui de l'owner.

Les états sont la traduction directe des `SiteGscFailure`, ce qui garantit qu'ils ne
peuvent pas mentir :

| Raison | Affichage | Action |
|---|---|---|
| `no-property` | « Aucune propriété mappée » | sélecteur de propriété |
| `no-authority`, `authority-lost` | « À reconnecter » | bouton *Reprendre* (te désigne comme autorité) |
| `no-google-account`, `missing-scope` | « Reconnecte ton compte Google » | lien de consentement |
| `token-refused` | « Google a refusé l'accès » | reprise possible |

*Reprendre* est ouvert à quiconque a accès au site (même gate que le mapping) et
n'exige aucun accord de l'autorité précédente : il écrase `gsc_authorized_user_id`
avec l'utilisateur courant après avoir validé que la propriété figure bien dans **ses**
propriétés vérifiées. Une reprise sur un site dont l'ancienne autorité est encore
valide est donc possible et volontaire — c'est le chemin de passation quand un
consultant part.

## Backfill

Objectif : ne casser aucune org qui fonctionne, et ne rien inventer là où rien ne
fonctionnait. On rejoue en SQL la résolution actuelle de `getOrgOwnerGscToken` :

```sql
UPDATE sites s SET gsc_authorized_user_id = (
  SELECT m.user_id FROM member m
  JOIN account a ON a.user_id = m.user_id AND a.provider_id = 'google'
  WHERE m.organization_id = s.org_id
    AND m.role IN ('owner','admin')
    AND a.scope ~ '(^|[[:space:],])https://www\.googleapis\.com/auth/webmasters\.readonly([[:space:],]|$)'
  ORDER BY CASE WHEN m.role = 'owner' THEN 0 ELSE 1 END, m.created_at
  LIMIT 1
) WHERE s.gsc_property IS NOT NULL;
```

Le regex reproduit exactement `hasWebmastersScope` — découpage sur virgules **ou**
espaces, égalité de jeton entière, pas un `LIKE` approximatif. `m.role IN ('owner','admin')`
est une égalité stricte, ce qui exclut les rôles multiples (`"admin,moderator"`) —
parité voulue avec l'`inArray` actuel.

Les sites qui repartent à `NULL` sont précisément ceux dont le worker échouait déjà :
le backfill ne casse rien, il rend visible une panne existante.

## Dérivation de l'activation par site

`deriveActivationFlags(orgId)` devient `deriveActivationFlags(orgId, siteId: string | null)`.

| Drapeau | Portée après |
|---|---|
| `add-site` | vrai dès que `siteId != null` |
| `connect-gsc` | ce site a une propriété **et** une autorité |
| `import-keywords` | mots-clés de ce site |
| `first-enrichment` | mots-clés enrichis de ce site |
| `invite-teammate` | reste par organisation — inviter n'est pas une action de site |

Un seul appelant à adapter, `activation/actions.ts:24`, qui résoudra le site actif via
`getSitesForActiveOrg()` — donc filtré par accès : un member ne dérivera jamais sur un
site qu'il ne voit pas.

**Décision explicite sur l'écartement de la carte** (pour qu'on ne la prenne pas plus
tard pour un oubli) : `activation_dismissals` reste clé sur `(userId, orgId)`, pas
`(userId, orgId, siteId)`. Écarter la carte exprime « je n'ai pas besoin de ce guide »,
une préférence de personne ; la refaire apparaître à chaque nouveau site serait du
harcèlement. La dérivation par site sert le cas visé — le coéquipier qui n'a jamais
rien écarté voit bien le tutoriel sur son site.

## Tests

En TDD, comme le reste du projet.

- **l'invariant headerless** : `getAccessToken` appelé **sans** `headers`, verrouillé
  par un espion sur les arguments. C'est le test qui protège du bug silencieux le plus
  coûteux du design.
- les six `SiteGscFailure`, un par branche
- `authority-lost` : autorité retirée de l'org, autorité rétrogradée, accès au site
  révoqué
- `userCanAccessSite` : owner, admin, member accordé, member non accordé, utilisateur
  d'une autre org
- le backfill, en suite DB : owner sans Google + admin avec scope → l'admin ; owner
  avec scope → l'owner ; personne → `NULL`
- l'isolation par site de `deriveActivationFlags`

Contrainte de test connue du projet : `bun mock.module` fuit entre fichiers d'un même
répertoire. On utilise l'injection de dépendances (pattern `__setTestCaller` de
`sites/access.ts`), pas `mock.module`.

## Code mort à retirer

- `src/lib/gsc/token.ts` entier : `getGscAccessToken`, zéro appelant. Sa suppression
  est même documentée dans `actions.ts:19`, mais le fichier est resté.
- `src/lib/gsc/owner.ts` se dissout : `canLinkGsc` renommée et déplacée vers les
  connecteurs, le reste supprimé.
- `knip` en fin de branche pour attraper le reste.

## Points d'attention

- Le backstop textuel `src/lib/security/tenancy-guard.ts` scanne les fichiers
  `"use server"` : y déclarer les nouveaux.
- `/api/worker/gsc-token/route.ts` doit mapper les nouvelles raisons d'échec.

## Backlog (hors périmètre)

- Le scope Search Console **complet** (`.../auth/webmasters`, en écriture) ne contient
  pas la chaîne `webmasters.readonly`. Quelqu'un qui a accordé le scope *plus large*
  est donc considéré comme non connecté — aujourd'hui déjà, par `hasWebmastersScope`.
  Parité gardée dans cette branche ; à corriger séparément.
- Écritures d'organisation et de facturation encore atteignables sous impersonation
  (`/subscription/*`, `/organization/*`) — demande une décision produit préalable.
- Fixtures de la base de test : pas de seed de facturation, aucun compte couvrant
  super-admin avec 2FA / utilisateur en onboarding / member sans site.
