# Autorité GSC au niveau du site — 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:** Faire de l'autorité Google Search Console un attribut du site plutôt que de l'organisation, avec un point de résolution unique qui revalide l'accès à chaque usage.

**Architecture:** `sites.gsc_authorized_user_id` stocke *qui* a branché son compte Google sur ce site. Un module unique, `src/lib/gsc/site-token.ts`, résout le jeton en revalidant à chaque appel que cette personne a toujours accès au site — les trois chemins de résolution actuels disparaissent. L'UI et le worker lisent la même sortie typée, donc ne peuvent plus diverger.

**Tech Stack:** Next.js 16 (App Router, Server Actions), Better Auth 1.6.23, Drizzle ORM + Postgres, bun test, Biome.

**Spec:** `docs/superpowers/specs/2026-07-26-gsc-site-authority-design.md` — la lire avant de commencer.

## Global Constraints

- **INVARIANT ABSOLU** : tout appel à `auth.api.getAccessToken` visant une autorité doit passer `body.userId` et **ne jamais** passer `headers`. Better Auth fait gagner la session sur le `userId` du body quand des headers sont présents (`node_modules/better-auth/dist/api/routes/account.mjs:239`) — on résoudrait le jeton du mauvais utilisateur, en silence.
- **`mock.module` fuit entre fichiers d'un même répertoire dans bun.** Pour tout module NOUVEAU, tester par injection de dépendances (pattern `__setTestCaller` de `src/lib/sites/access.ts`) — c'est pourquoi `deriveSiteGscState` et `resolveSiteGscToken` prennent un `deps`. Ne pas introduire `mock.module` dans un répertoire qui n'en a pas. Là où le harnais existant en dépend déjà (`src/lib/gsc/actions.test.ts`, `src/lib/keywords/prompt/actions.test.ts`), on l'adapte sans le réécrire : le convertir est hors périmètre.
- Gestionnaire de paquets : **bun**. Jamais `npm`/`npx` (échoue en `EOVERRIDE`). Nouvelle dépendance : `sfw bun add <pkg>`.
- Commits au format **Conventional Commits**. Travail sur la branche `feature/gsc-site-authority`, déjà créée.
- Tests : `bun run test <chemin>` (le script exporte `DB_TESTS=1` et pointe la base de test). Suites DB gardées par `describe.skipIf(!RUN_DB)`.
- Lint : `bun run lint` = `biome check`. La base a ~288 erreurs pré-existantes (SVG de drapeaux) — vérifier `biome check` sur **les fichiers touchés** uniquement.
- Langue : commentaires et messages utilisateur en français, identifiants en anglais — suivre le style du fichier voisin.

---

### Task 1: Extraire `userCanAccessSite(userId, siteId)`

Le worker tourne hors session : il ne peut pas utiliser `currentUserCanAccessSite`, qui lit `headers()`. On extrait la règle d'accès dans un helper à `userId` explicite, et on réécrit l'existant pour déléguer — une seule règle, pas deux formulations.

**Files:**
- Modify: `src/lib/sites/access.ts`
- Test: `src/lib/sites/user-can-access-site.test.ts` (créer)

**Interfaces:**
- Produces: `export async function userCanAccessSite(userId: string, siteId: string): Promise<boolean>`

Règle : l'utilisateur est membre de l'organisation **propriétaire du site**, et soit son rôle y est `owner`/`admin`, soit une ligne `site_access` existe pour ce couple. Noter que l'org n'est **pas** l'org active : elle est déduite de `sites.org_id`. C'est ce qui rend le helper utilisable hors requête.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `src/lib/sites/user-can-access-site.test.ts` :

```ts
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { member, organization, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { siteAccess, sites } from "@/lib/db/domain-schema";
import { userCanAccessSite } from "./access";

const RUN_DB = process.env.DB_TESTS === "1";
const ORG = "ucas-org";
const OTHER_ORG = "ucas-other-org";
const SITE = "ucas-site";

beforeAll(async () => {
  if (!RUN_DB) return;
  assertTestDb();
  await db.insert(user).values(
    ["owner", "admin", "granted", "ungranted", "stranger"].map((k) => ({
      id: `ucas-${k}`,
      name: k,
      email: `ucas-${k}@example.test`,
    })),
  );
  await db
    .insert(organization)
    .values([
      { id: ORG, name: "ucas", slug: ORG, createdAt: new Date() },
      { id: OTHER_ORG, name: "other", slug: OTHER_ORG, createdAt: new Date() },
    ]);
  await db.insert(member).values([
    { id: "ucas-m1", organizationId: ORG, userId: "ucas-owner", role: "owner", createdAt: new Date() },
    { id: "ucas-m2", organizationId: ORG, userId: "ucas-admin", role: "admin", createdAt: new Date() },
    { id: "ucas-m3", organizationId: ORG, userId: "ucas-granted", role: "member", createdAt: new Date() },
    { id: "ucas-m4", organizationId: ORG, userId: "ucas-ungranted", role: "member", createdAt: new Date() },
    { id: "ucas-m5", organizationId: OTHER_ORG, userId: "ucas-stranger", role: "owner", createdAt: new Date() },
  ]);
  await db.insert(sites).values({ id: SITE, orgId: ORG, name: "s", url: "https://s.test" });
  await db.insert(siteAccess).values({ siteId: SITE, userId: "ucas-granted" });
});

afterAll(async () => {
  if (!RUN_DB) return;
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(member).where(eq(member.organizationId, ORG));
  await db.delete(member).where(eq(member.organizationId, OTHER_ORG));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(organization).where(eq(organization.id, OTHER_ORG));
  for (const k of ["owner", "admin", "granted", "ungranted", "stranger"]) {
    await db.delete(user).where(eq(user.id, `ucas-${k}`));
  }
});

test.skipIf(!RUN_DB)("owner de l'org du site → accès", async () => {
  expect(await userCanAccessSite("ucas-owner", SITE)).toBe(true);
});

test.skipIf(!RUN_DB)("admin de l'org du site → accès", async () => {
  expect(await userCanAccessSite("ucas-admin", SITE)).toBe(true);
});

test.skipIf(!RUN_DB)("member avec site_access → accès", async () => {
  expect(await userCanAccessSite("ucas-granted", SITE)).toBe(true);
});

test.skipIf(!RUN_DB)("member sans site_access → refus", async () => {
  expect(await userCanAccessSite("ucas-ungranted", SITE)).toBe(false);
});

test.skipIf(!RUN_DB)("owner d'une AUTRE org → refus", async () => {
  expect(await userCanAccessSite("ucas-stranger", SITE)).toBe(false);
});

test.skipIf(!RUN_DB)("site inexistant → refus", async () => {
  expect(await userCanAccessSite("ucas-owner", "ucas-ghost")).toBe(false);
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/sites/user-can-access-site.test.ts`
Expected: FAIL — `userCanAccessSite` n'est pas exporté.

- [ ] **Step 3: Implémenter le helper**

Dans `src/lib/sites/access.ts`, ajouter après `getGrantedSiteIds` :

```ts
/** Accès à un site pour un userId EXPLICITE — utilisable hors requête (worker,
 *  broker de jeton GSC), là où `currentUserCanAccessSite` ne peut pas aller
 *  puisqu'il lit `headers()`. L'organisation n'est pas l'org active : elle est
 *  déduite de `sites.org_id`, sinon un appel hors session n'aurait aucun
 *  contexte. Fail-closed : site inconnu ou non-membre → false. */
export async function userCanAccessSite(
  userId: string,
  siteId: string,
): Promise<boolean> {
  const [site] = await db
    .select({ orgId: sites.orgId })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  if (!site) return false;
  const [m] = await db
    .select({ role: member.role })
    .from(member)
    .where(
      and(eq(member.organizationId, site.orgId), eq(member.userId, userId)),
    )
    .limit(1);
  if (!m) return false;
  if (isFullAccess(m.role)) return true;
  const [row] = await db
    .select({ siteId: siteAccess.siteId })
    .from(siteAccess)
    .where(and(eq(siteAccess.siteId, siteId), eq(siteAccess.userId, userId)))
    .limit(1);
  return Boolean(row);
}
```

Ajouter `sites` à l'import depuis `@/lib/db/domain-schema` (le fichier n'importe aujourd'hui que `siteAccess`).

- [ ] **Step 4: Réécrire `currentUserCanAccessSite` pour déléguer**

Remplacer le corps de `currentUserCanAccessSite` par :

```ts
/** Fail-closed per-site access check pour la session courante. Délègue à
 *  `userCanAccessSite` : une seule règle d'accès dans la base de code. */
export async function currentUserCanAccessSite(
  siteId: string,
): Promise<boolean> {
  const c = await currentCaller();
  // Fail closed: pas de caller résoluble = pas de session/rôle de confiance.
  if (!c) return false;
  return userCanAccessSite(c.userId, siteId);
}
```

- [ ] **Step 5: Lancer les tests**

Run: `bun run test src/lib/sites/`
Expected: PASS — les nouveaux tests et les suites existantes de `sites/`.

- [ ] **Step 6: Commit**

```bash
git add src/lib/sites/access.ts src/lib/sites/user-can-access-site.test.ts
git commit -m "feat(sites): helper d'accès au site à userId explicite

Le broker de jeton GSC tourne hors session et ne peut pas utiliser
currentUserCanAccessSite, qui lit headers(). L'existant délègue au
nouveau : une seule règle d'accès."
```

---

### Task 2: Colonne `gsc_authorized_user_id` + backfill

**Files:**
- Modify: `src/lib/db/domain-schema.ts:80` (table `sites`)
- Create: `drizzle/0070_*.sql` (généré, puis complété à la main)
- Test: `src/lib/db/gsc-authority-backfill.test.ts` (créer)

**Interfaces:**
- Produces: colonne Drizzle `sites.gscAuthorizedUserId` (`text`, nullable, FK `user.id` `ON DELETE SET NULL`)

- [ ] **Step 1: Ajouter la colonne au schéma Drizzle**

Dans `src/lib/db/domain-schema.ts`, table `sites`, juste après `gscProperty` :

```ts
    // Qui a branché SON compte Google sur ce site. L'autorité est un attribut
    // du site, pas de l'org : dans une agence, le consultant qui gère le site
    // a les accès Search Console du client, pas l'owner du compte SEO Insight.
    // SET NULL et pas CASCADE : supprimer un utilisateur ne supprime pas le
    // site. La propriété mappée (gscProperty) SURVIT à la perte d'autorité,
    // pour que la reprise en main soit un clic.
    gscAuthorizedUserId: text("gsc_authorized_user_id").references(
      () => user.id,
      { onDelete: "set null" },
    ),
```

Vérifier que `user` est bien importé depuis `./auth-schema` dans ce fichier ; l'ajouter sinon.

Ajouter l'index dans le bloc des index de la table :

```ts
    index("sites_gsc_authorized_user_idx").on(t.gscAuthorizedUserId),
```

- [ ] **Step 2: Générer la migration**

Run: `bun run db:generate`
Expected: création de `drizzle/0070_<nom>.sql` + `drizzle/meta/0070_snapshot.json` + entrée dans `_journal.json`. Vérifier que le SQL généré contient bien `ADD COLUMN "gsc_authorized_user_id"` et la création d'index, et rien d'autre.

- [ ] **Step 3: Ajouter le backfill au fichier de migration généré**

Append à la fin de `drizzle/0070_<nom>.sql` :

```sql
--> statement-breakpoint
-- Backfill : rejoue la résolution actuelle de getOrgOwnerGscToken (owner
-- d'abord, admin ensuite, compte Google porteur du scope obligatoire) pour ne
-- casser aucune org qui fonctionne. Le regex reproduit hasWebmastersScope :
-- découpage sur virgules OU espaces, égalité de jeton entière — pas un LIKE.
-- Les sites qui restent NULL sont ceux dont le worker échouait déjà.
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;
```

- [ ] **Step 4: Écrire le test du backfill**

Créer `src/lib/db/gsc-authority-backfill.test.ts`. Le test ne rejoue pas la migration : il vérifie que **la requête de backfill** choisit la bonne personne. Extraire la requête telle quelle et l'exécuter via `db.execute(sql\`...\`)` sur des données de test.

```ts
import { afterEach, beforeEach, expect, test } from "bun:test";
import { eq, sql } from "drizzle-orm";
import { account, member, organization, user } from "@/lib/db/auth-schema";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";

const RUN_DB = process.env.DB_TESTS === "1";
const SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
const ORG = "bf-org";
const SITE = "bf-site";

const BACKFILL = 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`;

async function seed(opts: {
  ownerScope: string | null;
  adminScope: string | null;
}) {
  await db.insert(user).values([
    { id: "bf-owner", name: "o", email: "bf-owner@example.test" },
    { id: "bf-admin", name: "a", email: "bf-admin@example.test" },
  ]);
  await db
    .insert(organization)
    .values({ id: ORG, name: "bf", slug: ORG, createdAt: new Date() });
  await db.insert(member).values([
    { id: "bf-m1", organizationId: ORG, userId: "bf-owner", role: "owner", createdAt: new Date() },
    { id: "bf-m2", organizationId: ORG, userId: "bf-admin", role: "admin", createdAt: new Date() },
  ]);
  if (opts.ownerScope !== null) {
    await db.insert(account).values({
      id: "bf-a1", accountId: "g1", providerId: "google",
      userId: "bf-owner", scope: opts.ownerScope, createdAt: new Date(), updatedAt: new Date(),
    });
  }
  if (opts.adminScope !== null) {
    await db.insert(account).values({
      id: "bf-a2", accountId: "g2", providerId: "google",
      userId: "bf-admin", scope: opts.adminScope, createdAt: new Date(), updatedAt: new Date(),
    });
  }
  await db.insert(sites).values({
    id: SITE, orgId: ORG, name: "s", url: "https://s.test",
    gscProperty: "sc-domain:s.test",
  });
}

async function authority(): Promise<string | null> {
  const [row] = await db
    .select({ a: sites.gscAuthorizedUserId })
    .from(sites)
    .where(eq(sites.id, SITE));
  return row?.a ?? null;
}

beforeEach(() => {
  if (RUN_DB) assertTestDb();
});

afterEach(async () => {
  if (!RUN_DB) return;
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(account).where(eq(account.userId, "bf-owner"));
  await db.delete(account).where(eq(account.userId, "bf-admin"));
  await db.delete(member).where(eq(member.organizationId, ORG));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(user).where(eq(user.id, "bf-owner"));
  await db.delete(user).where(eq(user.id, "bf-admin"));
});

test.skipIf(!RUN_DB)("owner sans Google + admin avec scope → l'admin", async () => {
  await seed({ ownerScope: null, adminScope: SCOPE });
  await db.execute(BACKFILL);
  expect(await authority()).toBe("bf-admin");
});

test.skipIf(!RUN_DB)("owner ET admin avec scope → l'owner gagne", async () => {
  await seed({ ownerScope: SCOPE, adminScope: SCOPE });
  await db.execute(BACKFILL);
  expect(await authority()).toBe("bf-owner");
});

test.skipIf(!RUN_DB)("scopes séparés par des espaces → reconnus", async () => {
  await seed({ ownerScope: `openid email ${SCOPE}`, adminScope: null });
  await db.execute(BACKFILL);
  expect(await authority()).toBe("bf-owner");
});

test.skipIf(!RUN_DB)("scope webmasters SANS readonly → non reconnu", async () => {
  await seed({
    ownerScope: "https://www.googleapis.com/auth/webmasters",
    adminScope: null,
  });
  await db.execute(BACKFILL);
  expect(await authority()).toBeNull();
});

test.skipIf(!RUN_DB)("personne avec le scope → NULL", async () => {
  await seed({ ownerScope: "openid email", adminScope: null });
  await db.execute(BACKFILL);
  expect(await authority()).toBeNull();
});
```

- [ ] **Step 5: Lancer le test — il doit échouer**

Run: `bun run test src/lib/db/gsc-authority-backfill.test.ts`
Expected: FAIL — la colonne `gsc_authorized_user_id` n'existe pas encore dans la base de test.

- [ ] **Step 6: Appliquer la migration sur la base de test puis relancer**

Run: `bun run db:migrate:test` puis `bun run test src/lib/db/gsc-authority-backfill.test.ts`
Expected: PASS sur les cinq cas.

- [ ] **Step 7: Appliquer sur la base de dev**

Run: `bun run db:migrate`

Note : sous un serveur `next dev` actif, un burst transitoire « column does not exist » est normal (pool postgres.js) — re-tester avant de diagnostiquer.

- [ ] **Step 8: Commit**

```bash
git add src/lib/db/domain-schema.ts drizzle/ src/lib/db/gsc-authority-backfill.test.ts
git commit -m "feat(db): colonne sites.gsc_authorized_user_id + backfill

Rejoue la résolution owner-puis-admin actuelle pour ne casser aucune org
qui fonctionne. Les sites qui restent NULL sont ceux dont le worker
échouait déjà en silence."
```

---

### Task 3: Le point de résolution unique

Le cœur du design. Deux fonctions : une dérivation **sans réseau** pour l'affichage, et la résolution complète qui appelle Google.

**Files:**
- Create: `src/lib/gsc/site-state.ts`
- Create: `src/lib/gsc/site-token.ts`
- Test: `src/lib/gsc/site-state.test.ts`, `src/lib/gsc/site-token.test.ts`

**Interfaces:**
- Consumes: `userCanAccessSite` (Task 1), `sites.gscAuthorizedUserId` (Task 2), `hasWebmastersScope` de `./scope`
- Produces:

```ts
// site-state.ts
export type SiteGscFailure =
  | "no-property" | "no-authority" | "authority-lost"
  | "no-google-account" | "missing-scope" | "token-refused";
export type SiteGscState = Exclude<SiteGscFailure, "token-refused"> | "ok";
export type SiteStateDeps = {
  canAuthorityAccessSite?: (userId: string, siteId: string) => Promise<boolean>;
};
/** Union DISCRIMINÉE : sur "ok", propriété/utilisateur/compte sont garantis
 *  non-null par le type, ce qui supprime les `as string`. */
export type SiteGscStateResult =
  | {
      state: "ok";
      property: string;
      authorityUserId: string;
      authorityAccountId: string;
    }
  | {
      state: Exclude<SiteGscState, "ok">;
      property: string | null;
      authorityUserId: string | null;
      authorityAccountId: string | null;
    };
export async function deriveSiteGscState(
  siteId: string, deps?: SiteStateDeps,
): Promise<SiteGscStateResult>;

// site-token.ts
export type SiteGscTokenResult =
  | {
      ok: true;
      accessToken: string;
      property: string;
      authorityUserId: string;
      authorityAccountId: string;
    }
  | { ok: false; reason: SiteGscFailure };
export type SiteTokenDeps = SiteStateDeps & {
  getAccessToken?: (args: {
    body: { providerId: string; userId: string; accountId: string };
  }) => Promise<{ accessToken?: string } | null>;
};
export async function resolveSiteGscToken(
  siteId: string, deps?: SiteTokenDeps,
): Promise<SiteGscTokenResult>;
```

**`accountId` est obligatoire dans l'appel à Better Auth.** Sans lui,
`account.mjs:270` refait sa propre sélection arbitraire parmi les comptes Google de
l'utilisateur, indépendamment de celui qu'on vient de valider : le jeton peut venir
d'un compte sans droit sur la propriété, et l'échec remonte en 403 Google opaque au
lieu d'une raison typée.

`sites.gscAuthorizedAccountId` porte `account.account_id` (l'identifiant Google),
**pas** `account.id` — c'est ce que `body.accountId` attend.

Pourquoi deux fonctions : lister dix sites dans l'UI ne doit pas déclencher dix appels Google. `deriveSiteGscState` ne fait que des requêtes SQL. `token-refused` n'est donc jamais un état affichable *a priori* — il n'apparaît qu'après une tentative réelle.

- [ ] **Step 1: Écrire le test de `deriveSiteGscState`**

Créer `src/lib/gsc/site-state.test.ts` avec une suite DB couvrant les cinq états. Injecter `canAccess` pour piloter `authority-lost` sans manipuler `site_access`.

```ts
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { account, member, organization, user } from "@/lib/db/auth-schema";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";
import { deriveSiteGscState } from "./site-state";

const RUN_DB = process.env.DB_TESTS === "1";
const SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
const ORG = "sgs-org";
const yes = async () => true;
const no = async () => false;

beforeAll(async () => {
  if (!RUN_DB) return;
  assertTestDb();
  await db.insert(user).values([
    { id: "sgs-auth", name: "a", email: "sgs-auth@example.test" },
    { id: "sgs-nogoogle", name: "n", email: "sgs-nogoogle@example.test" },
    { id: "sgs-noscope", name: "s", email: "sgs-noscope@example.test" },
  ]);
  await db.insert(organization).values({ id: ORG, name: "sgs", slug: ORG, createdAt: new Date() });
  await db.insert(member).values({
    id: "sgs-m1", organizationId: ORG, userId: "sgs-auth", role: "owner", createdAt: new Date(),
  });
  await db.insert(account).values([
    { id: "sgs-a1", accountId: "g1", providerId: "google", userId: "sgs-auth", scope: SCOPE, createdAt: new Date(), updatedAt: new Date() },
    { id: "sgs-a2", accountId: "g2", providerId: "google", userId: "sgs-noscope", scope: "openid email", createdAt: new Date(), updatedAt: new Date() },
  ]);
  await db.insert(sites).values([
    { id: "sgs-noprop", orgId: ORG, name: "n", url: "https://n.test" },
    { id: "sgs-noauth", orgId: ORG, name: "a", url: "https://a.test", gscProperty: "sc-domain:a.test" },
    { id: "sgs-ok", orgId: ORG, name: "o", url: "https://o.test", gscProperty: "sc-domain:o.test", gscAuthorizedUserId: "sgs-auth" },
    { id: "sgs-nog", orgId: ORG, name: "g", url: "https://g.test", gscProperty: "sc-domain:g.test", gscAuthorizedUserId: "sgs-nogoogle" },
    { id: "sgs-nos", orgId: ORG, name: "s", url: "https://s.test", gscProperty: "sc-domain:s.test", gscAuthorizedUserId: "sgs-noscope" },
  ]);
});

afterAll(async () => {
  if (!RUN_DB) return;
  for (const id of ["sgs-noprop", "sgs-noauth", "sgs-ok", "sgs-nog", "sgs-nos"]) {
    await db.delete(sites).where(eq(sites.id, id));
  }
  await db.delete(account).where(eq(account.userId, "sgs-auth"));
  await db.delete(account).where(eq(account.userId, "sgs-noscope"));
  await db.delete(member).where(eq(member.organizationId, ORG));
  await db.delete(organization).where(eq(organization.id, ORG));
  for (const id of ["sgs-auth", "sgs-nogoogle", "sgs-noscope"]) {
    await db.delete(user).where(eq(user.id, id));
  }
});

test.skipIf(!RUN_DB)("site inconnu → no-property", async () => {
  expect((await deriveSiteGscState("sgs-ghost", { canAccess: yes })).state).toBe("no-property");
});

test.skipIf(!RUN_DB)("aucune propriété mappée → no-property", async () => {
  expect((await deriveSiteGscState("sgs-noprop", { canAccess: yes })).state).toBe("no-property");
});

test.skipIf(!RUN_DB)("propriété sans autorité → no-authority", async () => {
  expect((await deriveSiteGscState("sgs-noauth", { canAccess: yes })).state).toBe("no-authority");
});

test.skipIf(!RUN_DB)("autorité sans accès au site → authority-lost", async () => {
  expect((await deriveSiteGscState("sgs-ok", { canAccess: no })).state).toBe("authority-lost");
});

test.skipIf(!RUN_DB)("autorité sans compte Google → no-google-account", async () => {
  expect((await deriveSiteGscState("sgs-nog", { canAccess: yes })).state).toBe("no-google-account");
});

test.skipIf(!RUN_DB)("compte Google sans le scope → missing-scope", async () => {
  expect((await deriveSiteGscState("sgs-nos", { canAccess: yes })).state).toBe("missing-scope");
});

test.skipIf(!RUN_DB)("tout en place → ok, avec propriété et autorité", async () => {
  const r = await deriveSiteGscState("sgs-ok", { canAccess: yes });
  expect(r.state).toBe("ok");
  expect(r.property).toBe("sc-domain:o.test");
  expect(r.authorityUserId).toBe("sgs-auth");
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/gsc/site-state.test.ts`
Expected: FAIL — le module `./site-state` n'existe pas.

- [ ] **Step 3: Implémenter `site-state.ts`**

```ts
// src/lib/gsc/site-state.ts — état GSC d'un site, dérivé SANS appel réseau.
import "server-only";
import { and, eq } from "drizzle-orm";
import { account } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";
import { userCanAccessSite } from "@/lib/sites/access";
import { hasWebmastersScope } from "./scope";

export type SiteGscFailure =
  | "no-property"
  | "no-authority"
  | "authority-lost"
  | "no-google-account"
  | "missing-scope"
  | "token-refused";

/** `token-refused` n'est pas dérivable : il n'existe qu'après une tentative
 *  réelle auprès de Google. D'où l'exclusion. */
export type SiteGscState = Exclude<SiteGscFailure, "token-refused"> | "ok";

export type SiteStateDeps = {
  canAccess?: (userId: string, siteId: string) => Promise<boolean>;
};

export type SiteGscStateResult = {
  state: SiteGscState;
  property: string | null;
  authorityUserId: string | null;
};

/** Diagnostic complet SANS réseau : lister dix sites dans l'UI ne doit pas
 *  déclencher dix appels Google. `resolveSiteGscToken` s'appuie dessus puis
 *  ajoute le seul appel qui reste. */
export async function deriveSiteGscState(
  siteId: string,
  deps?: SiteStateDeps,
): Promise<SiteGscStateResult> {
  const canAccess = deps?.canAccess ?? userCanAccessSite;
  const [site] = await db
    .select({
      property: sites.gscProperty,
      authorityUserId: sites.gscAuthorizedUserId,
    })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  const property = site?.property ?? null;
  const authorityUserId = site?.authorityUserId ?? null;
  if (!property) return { state: "no-property", property, authorityUserId };
  if (!authorityUserId) {
    return { state: "no-authority", property, authorityUserId };
  }
  // Revalidation à l'usage : /organization/leave ne déclenche aucun hook
  // Better Auth, donc l'autorité stockée ne peut pas faire foi à elle seule.
  if (!(await canAccess(authorityUserId, siteId))) {
    return { state: "authority-lost", property, authorityUserId };
  }
  const [googleAccount] = await db
    .select({ scope: account.scope })
    .from(account)
    .where(
      and(
        eq(account.userId, authorityUserId),
        eq(account.providerId, "google"),
      ),
    )
    .limit(1);
  if (!googleAccount) {
    return { state: "no-google-account", property, authorityUserId };
  }
  if (!hasWebmastersScope(googleAccount.scope)) {
    return { state: "missing-scope", property, authorityUserId };
  }
  return { state: "ok", property, authorityUserId };
}
```

- [ ] **Step 4: Lancer le test**

Run: `bun run test src/lib/gsc/site-state.test.ts`
Expected: PASS sur les sept cas.

- [ ] **Step 5: Écrire le test de `resolveSiteGscToken`, invariant headerless en tête**

Créer `src/lib/gsc/site-token.test.ts` :

```ts
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { account, member, organization, user } from "@/lib/db/auth-schema";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";
import { resolveSiteGscToken } from "./site-token";

const RUN_DB = process.env.DB_TESTS === "1";
const SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
const ORG = "sgt-org";
const yes = async () => true;
const no = async () => false;

beforeAll(async () => {
  if (!RUN_DB) return;
  assertTestDb();
  await db.insert(user).values({ id: "sgt-auth", name: "a", email: "sgt-auth@example.test" });
  await db.insert(organization).values({ id: ORG, name: "sgt", slug: ORG, createdAt: new Date() });
  await db.insert(member).values({
    id: "sgt-m1", organizationId: ORG, userId: "sgt-auth", role: "owner", createdAt: new Date(),
  });
  await db.insert(account).values({
    id: "sgt-a1", accountId: "g1", providerId: "google", userId: "sgt-auth",
    scope: SCOPE, createdAt: new Date(), updatedAt: new Date(),
  });
  await db.insert(sites).values({
    id: "sgt-site", orgId: ORG, name: "s", url: "https://s.test",
    gscProperty: "sc-domain:s.test", gscAuthorizedUserId: "sgt-auth",
  });
});

afterAll(async () => {
  if (!RUN_DB) return;
  await db.delete(sites).where(eq(sites.id, "sgt-site"));
  await db.delete(account).where(eq(account.userId, "sgt-auth"));
  await db.delete(member).where(eq(member.organizationId, ORG));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(user).where(eq(user.id, "sgt-auth"));
});

// LE test du design. Si getAccessToken reçoit des headers, Better Auth fait
// gagner la session de l'appelant sur body.userId et on résout le jeton du
// MAUVAIS utilisateur — sans erreur, avec des données du mauvais compte Google.
test.skipIf(!RUN_DB)("n'envoie JAMAIS de headers à getAccessToken", async () => {
  let captured: unknown;
  await resolveSiteGscToken("sgt-site", {
    canAccess: yes,
    getAccessToken: async (args) => {
      captured = args;
      return { accessToken: "tok" };
    },
  });
  expect(captured).toEqual({
    body: { providerId: "google", userId: "sgt-auth" },
  });
  expect(Object.keys(captured as object)).not.toContain("headers");
});

test.skipIf(!RUN_DB)("chaîne complète → jeton + propriété + autorité", async () => {
  const r = await resolveSiteGscToken("sgt-site", {
    canAccess: yes,
    getAccessToken: async () => ({ accessToken: "tok" }),
  });
  expect(r).toEqual({
    ok: true,
    accessToken: "tok",
    property: "sc-domain:s.test",
    authorityUserId: "sgt-auth",
  });
});

test.skipIf(!RUN_DB)("autorité sans accès → authority-lost, sans appeler Google", async () => {
  let called = false;
  const r = await resolveSiteGscToken("sgt-site", {
    canAccess: no,
    getAccessToken: async () => {
      called = true;
      return { accessToken: "tok" };
    },
  });
  expect(r).toEqual({ ok: false, reason: "authority-lost" });
  expect(called).toBe(false);
});

test.skipIf(!RUN_DB)("getAccessToken qui jette → token-refused", async () => {
  const r = await resolveSiteGscToken("sgt-site", {
    canAccess: yes,
    getAccessToken: async () => {
      throw new Error("refresh token révoqué");
    },
  });
  expect(r).toEqual({ ok: false, reason: "token-refused" });
});

test.skipIf(!RUN_DB)("getAccessToken sans accessToken → token-refused", async () => {
  const r = await resolveSiteGscToken("sgt-site", {
    canAccess: yes,
    getAccessToken: async () => ({}),
  });
  expect(r).toEqual({ ok: false, reason: "token-refused" });
});

test.skipIf(!RUN_DB)("propage l'état dérivé sans appeler Google", async () => {
  const r = await resolveSiteGscToken("sgt-ghost", { canAccess: yes });
  expect(r).toEqual({ ok: false, reason: "no-property" });
});
```

- [ ] **Step 6: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/gsc/site-token.test.ts`
Expected: FAIL — le module `./site-token` n'existe pas.

- [ ] **Step 7: Implémenter `site-token.ts`**

```ts
// src/lib/gsc/site-token.ts — LE point de résolution du jeton GSC d'un site.
// Remplace getOrgOwnerGscToken, resolveWorkerGscToken et getGscAccessToken :
// un seul chemin, donc plus de divergence possible entre l'UI et le worker.
import "server-only";
import { auth } from "@/lib/auth";
import {
  deriveSiteGscState,
  type SiteGscFailure,
  type SiteStateDeps,
} from "./site-state";

export type SiteGscTokenResult =
  | {
      ok: true;
      accessToken: string;
      property: string;
      authorityUserId: string;
    }
  | { ok: false; reason: SiteGscFailure };

export type SiteTokenDeps = SiteStateDeps & {
  getAccessToken?: (args: {
    body: { providerId: string; userId: string };
  }) => Promise<{ accessToken?: string } | null>;
};

/** INVARIANT : pas de `headers` dans l'appel. Better Auth fait gagner la
 *  session sur body.userId dès que des headers sont présents
 *  (account.mjs:239) — on résoudrait le jeton de l'APPELANT au lieu de celui
 *  de l'autorité, en silence. Verrouillé par un test. */
const defaultGetAccessToken: NonNullable<SiteTokenDeps["getAccessToken"]> = (
  args,
) => auth.api.getAccessToken(args);

/**
 * Site → propriété + autorité → revalidation de l'accès → compte Google +
 * scope → jeton frais. Better Auth rafraîchit et déchiffre
 * (`encryptOAuthTokens` est actif) — ne jamais lire `account.access_token`
 * directement.
 *
 * Cette fonction EST la frontière d'autorisation : sur le chemin headerless,
 * Better Auth ne vérifie plus rien pour nous, par conception.
 */
export async function resolveSiteGscToken(
  siteId: string,
  deps?: SiteTokenDeps,
): Promise<SiteGscTokenResult> {
  const derived = await deriveSiteGscState(siteId, deps);
  if (derived.state !== "ok") {
    return { ok: false, reason: derived.state };
  }
  // `state === "ok"` garantit les deux non-null ; on rassure TypeScript.
  const property = derived.property as string;
  const authorityUserId = derived.authorityUserId as string;
  const getAccessToken = deps?.getAccessToken ?? defaultGetAccessToken;
  try {
    const res = await getAccessToken({
      body: { providerId: "google", userId: authorityUserId },
    });
    if (!res?.accessToken) {
      console.error("[gsc] getAccessToken sans accessToken", { siteId });
      return { ok: false, reason: "token-refused" };
    }
    // Trace d'audit : jamais le jeton, juste qui/quand. Sert aussi à repérer
    // un sync déclenché sous impersonation (l'écriture de rafraîchissement
    // OAuth touche le compte du client).
    console.info("[gsc] jeton résolu", {
      siteId,
      authorityUserId,
      at: new Date().toISOString(),
    });
    return { ok: true, accessToken: res.accessToken, property, authorityUserId };
  } catch (err) {
    // Cause typique : refresh token Google révoqué, ou BETTER_AUTH_SECRET
    // changé (déchiffrement impossible). Le message suffit — jamais le jeton.
    console.error("[gsc] getAccessToken en échec", {
      siteId,
      error: err instanceof Error ? err.message : String(err),
    });
    return { ok: false, reason: "token-refused" };
  }
}
```

- [ ] **Step 8: Lancer les tests**

Run: `bun run test src/lib/gsc/site-token.test.ts src/lib/gsc/site-state.test.ts`
Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add src/lib/gsc/site-state.ts src/lib/gsc/site-token.ts src/lib/gsc/site-state.test.ts src/lib/gsc/site-token.test.ts
git commit -m "feat(gsc): point de résolution unique du jeton par site

Dérivation sans réseau pour l'affichage, résolution complète pour l'usage.
Le retour typé rend l'état de santé impossible à désynchroniser du chemin
réel. Un test verrouille l'invariant headerless."
```

---

### Task 4: Basculer le worker

**Files:**
- Modify: `src/app/api/worker/gsc-token/route.ts:5,46-49`
- Delete: `src/lib/gsc/worker-token.ts`, `src/lib/gsc/worker-token.test.ts`
- Modify: `src/lib/gsc/worker-route.test.ts`

**Interfaces:**
- Consumes: `resolveSiteGscToken` (Task 3)

Le worker Rust attend aujourd'hui `{ accessToken, property }` en 200 et un 404 sinon. On garde ce contrat, en ajoutant la raison dans le corps du 404 — le worker peut l'ignorer, mais elle atterrit dans nos logs d'accès.

- [ ] **Step 1: Adapter le test de route**

Dans `src/lib/gsc/worker-route.test.ts`, remplacer le mock de `@/lib/gsc/worker-token` par un mock de `@/lib/gsc/site-token` renvoyant la nouvelle forme :

```ts
const resolveSiteGscToken = mock(async (siteId: string) =>
  siteId === "ok"
    ? {
        ok: true as const,
        accessToken: "tok",
        property: "sc-domain:x.test",
        authorityUserId: "u1",
        authorityAccountId: "g1",
      }
    : { ok: false as const, reason: "no-authority" as const },
);
mock.module("@/lib/gsc/site-token", () => ({ resolveSiteGscToken }));
```

Ajouter un test : un site non résoluble renvoie 404 **et** la raison.

```ts
test("404 avec la raison quand le site n'est pas résoluble", async () => {
  const res = await post({ siteId: "ko" });
  expect(res.status).toBe(404);
  expect(await res.json()).toEqual({
    error: "not resolvable",
    reason: "no-authority",
  });
});
```

Reprendre la fonction `post` déjà présente dans le fichier — ne pas en écrire une seconde.

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/gsc/worker-route.test.ts`
Expected: FAIL — la route importe encore `worker-token`.

- [ ] **Step 3: Modifier la route**

Remplacer l'import ligne 5 :

```ts
import { resolveSiteGscToken } from "@/lib/gsc/site-token";
```

et le bloc final :

```ts
  const resolved = await resolveSiteGscToken(siteId);
  if (!resolved.ok) {
    // La raison part dans le corps : le worker peut l'ignorer, mais elle
    // atterrit dans nos logs et rend la panne diagnosticable sans rejouer.
    return Response.json(
      { error: "not resolvable", reason: resolved.reason },
      { status: 404 },
    );
  }
  return Response.json({
    accessToken: resolved.accessToken,
    property: resolved.property,
  });
```

- [ ] **Step 4: Supprimer l'ancien module**

```bash
git rm src/lib/gsc/worker-token.ts src/lib/gsc/worker-token.test.ts
```

- [ ] **Step 5: Lancer les tests**

Run: `bun run test src/lib/gsc/`
Expected: PASS. `src/lib/gsc/actions.test.ts` échoue encore — normal, Task 5 s'en occupe.

- [ ] **Step 6: Commit**

```bash
git add -A src/app/api/worker/gsc-token src/lib/gsc
git commit -m "refactor(gsc): brancher le worker sur la résolution par site

Supprime resolveWorkerGscToken (règle owner-strict divergente de l'UI).
Le 404 porte désormais la raison de l'échec."
```

---

### Task 5: Server actions GSC

Le gros morceau : gates par accès au site, autorité tamponnée au mapping, statut refondu par site.

**Files:**
- Modify: `src/lib/gsc/actions.ts` (réécriture large)
- Create: `src/lib/gsc/failure-message.ts` (module pur — `"use server"` n'exporte que de l'async)
- Modify: `src/lib/gsc/actions.test.ts`

**Interfaces:**
- Consumes: `deriveSiteGscState`, `resolveSiteGscToken` (Task 3), `currentUserCanAccessSite`, `getSitesForActiveOrg` (`@/lib/sites/access`)
- Produces:

```ts
export type GscSiteRow = {
  id: string;
  name: string;
  url: string | null;
  gscProperty: string | null;
  authorityUserId: string | null;
  authorityName: string | null;
  isMine: boolean;
  state: SiteGscState;
};
/** Une propriété vérifiée, avec le compte Google qui la voit. C'est ce qui
 *  permet au mapping d'épingler le bon compte sans demander à l'utilisateur
 *  de le choisir : il choisit une propriété, on en déduit le compte. */
export type OwnedGscProperty = GscProperty & { accountId: string };
export type GscStatus = {
  myGoogle: "linked" | "missing-scope" | "none";
  properties: OwnedGscProperty[];
  propertiesError: boolean;
  sites: GscSiteRow[];
};
export async function getGscStatusAction(): Promise<GscStatus>;
export async function setSiteGscPropertyAction(
  siteId: string, property: string | null,
): Promise<SetSiteGscResult>;
export async function syncGscKeywordsAction(siteId: string): Promise<SyncGscResult>;
```

**Décision de conception à respecter** : il n'y a **pas** d'action « Reprendre » séparée. Reprendre la main, c'est rappeler `setSiteGscPropertyAction(siteId, propriétéActuelle)` — l'action valide la propriété contre les propriétés vérifiées de *l'appelant* et le tamponne comme autorité. Une action de plus ferait deux chemins d'écriture pour un seul comportement.

- [ ] **Step 1: Réécrire le harnais de test**

Dans `src/lib/gsc/actions.test.ts`, supprimer le `mock.module("./owner", ...)` (le module disparaît en Task 7) et le remplacer par un mock de `@/lib/sites/access` et de `./site-state` / `./site-token`. Piloter l'accès par une variable, comme `ROLE` l'était.

```ts
let CAN_ACCESS = true;
mock.module("@/lib/sites/access", () => ({
  currentUserCanAccessSite: async () => CAN_ACCESS,
  getSitesForActiveOrg: async () => [
    { id: "gsc-test-site", name: "s", plan: "https://s.test", primaryLanguage: "fr" },
  ],
  userCanAccessSite: async () => CAN_ACCESS,
}));
```

- [ ] **Step 2: Ajouter les tests de gate et de tamponnage**

```ts
test("mapping refusé sans accès au site", async () => {
  CAN_ACCESS = false;
  const res = await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  expect(res).toEqual({ ok: false, error: "Accès refusé à ce site." });
  CAN_ACCESS = true;
});

test("mapping réussi tamponne l'appelant comme autorité", async () => {
  CAN_ACCESS = true;
  const res = await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  expect(res).toEqual({ ok: true });
  const [row] = await db
    .select({
      p: sitesTable.gscProperty,
      a: sitesTable.gscAuthorizedUserId,
    })
    .from(sitesTable)
    .where(eq(sitesTable.id, "gsc-test-site"));
  expect(row.p).toBe("sc-domain:example.com");
  expect(row.a).toBe("gsc-test-user");
});

test("effacer la propriété efface l'autorité", async () => {
  CAN_ACCESS = true;
  await setSiteGscPropertyAction("gsc-test-site", null);
  const [row] = await db
    .select({
      p: sitesTable.gscProperty,
      a: sitesTable.gscAuthorizedUserId,
    })
    .from(sitesTable)
    .where(eq(sitesTable.id, "gsc-test-site"));
  expect(row.p).toBeNull();
  expect(row.a).toBeNull();
});

test("sync refusé sans accès au site", async () => {
  CAN_ACCESS = false;
  const res = await syncGscKeywordsAction("gsc-test-site");
  expect(res).toEqual({ ok: false, error: "Accès refusé à ce site." });
  CAN_ACCESS = true;
});
```

Conserver et adapter les tests existants du fichier qui restent pertinents (collision de propriété, propriété non vérifiée, site hors org).

- [ ] **Step 3: Lancer les tests pour vérifier qu'ils échouent**

Run: `bun run test src/lib/gsc/actions.test.ts`
Expected: FAIL.

- [ ] **Step 4: Réécrire `getGscStatusAction`**

Le statut ne parle plus de l'org : il parle de **mon** compte Google et des sites auxquels **j'ai** accès.

```ts
/** Mes comptes Google porteurs du scope Search Console. Pluriel volontaire :
 *  `accountLinking.allowDifferentEmails` est activé pour le cas agence, où un
 *  consultant lie la Search Console de plusieurs clients. */
async function myScopedGoogleAccounts(
  userId: string,
): Promise<{ accountId: string }[]> {
  const rows = await db
    .select({ accountId: account.accountId, scope: account.scope })
    .from(account)
    .where(and(eq(account.userId, userId), eq(account.providerId, "google")))
    .orderBy(account.createdAt, account.accountId);
  return rows
    .filter((r) => hasWebmastersScope(r.scope))
    .map((r) => ({ accountId: r.accountId }));
}

/** Statut du compte Google de l'utilisateur COURANT. `linked` dès qu'AU MOINS
 *  un compte porte le scope — même règle que le backfill de la migration. */
async function myGoogleState(
  userId: string,
  scopedCount: number,
): Promise<"linked" | "missing-scope" | "none"> {
  if (scopedCount > 0) return "linked";
  const [any] = await db
    .select({ id: account.id })
    .from(account)
    .where(and(eq(account.userId, userId), eq(account.providerId, "google")))
    .limit(1);
  return any ? "missing-scope" : "none";
}

export async function getGscStatusAction(): Promise<GscStatus> {
  const userId = await getActiveUserId();
  const [scopedAccounts, visibleSites] = await Promise.all([
    myScopedGoogleAccounts(userId),
    getSitesForActiveOrg(),
  ]);
  const myGoogle = await myGoogleState(userId, scopedAccounts.length);

  // Agrège les propriétés vérifiées de TOUS mes comptes scopés, en gardant
  // pour chacune le compte qui la voit. C'est ce qui permet au mapping
  // d'épingler le bon compte sans sélecteur dédié dans l'interface.
  const properties: OwnedGscProperty[] = [];
  const seen = new Set<string>();
  let propertiesError = false;
  for (const { accountId } of scopedAccounts) {
    try {
      const token = await auth.api.getAccessToken({
        body: { providerId: "google", userId, accountId },
      });
      if (!token?.accessToken) continue;
      for (const p of await fetchGscProperties(token.accessToken)) {
        // Deux comptes peuvent voir la même propriété : le premier gagne,
        // l'ordre est stable (createdAt puis accountId).
        if (seen.has(p.siteUrl)) continue;
        seen.add(p.siteUrl);
        properties.push({ ...p, accountId });
      }
    } catch (err) {
      // Un compte en échec ne doit pas masquer les propriétés des autres.
      console.error("[gsc] échec fetchGscProperties (sites.list)", {
        userId,
        accountId,
        error: err instanceof Error ? err.message : String(err),
      });
      propertiesError = true;
    }
  }

  const rows: GscSiteRow[] = await Promise.all(
    visibleSites.map(async (s) => {
      const derived = await deriveSiteGscState(s.id);
      const authorityName = derived.authorityUserId
        ? ((
            await db
              .select({ name: user.name })
              .from(user)
              .where(eq(user.id, derived.authorityUserId))
              .limit(1)
          )[0]?.name ?? null)
        : null;
      return {
        id: s.id,
        name: s.name,
        url: s.plan,
        gscProperty: derived.property,
        authorityUserId: derived.authorityUserId,
        authorityName,
        isMine: derived.authorityUserId === userId,
        state: derived.state,
      };
    }),
  );

  return { myGoogle, properties, propertiesError, sites: rows };
}
```

Note sur `s.plan` : le type `Site` du projet stocke l'URL dans le champ `plan` (voir `createSiteAction` qui renvoie `{ id, name, plan: url, primaryLanguage }`). Ne pas « corriger » ce nom ici, c'est hors périmètre.

- [ ] **Step 5: Réécrire `setSiteGscPropertyAction`**

```ts
/** Mappe (ou efface) la propriété GSC d'un site ET tamponne l'appelant comme
 *  autorité. C'est aussi le chemin de « reprise en main » : rappeler l'action
 *  avec la propriété déjà en place suffit à devenir la nouvelle autorité.
 *  Aucun accord de l'autorité précédente n'est requis — c'est le chemin de
 *  passation quand un consultant part. */
export async function setSiteGscPropertyAction(
  siteId: string,
  property: string | null,
): Promise<SetSiteGscResult> {
  if (!(await currentUserCanAccessSite(siteId))) {
    return { ok: false, error: "Accès refusé à ce site." };
  }
  const [orgId, userId] = await Promise.all([
    getActiveOrgId(),
    getActiveUserId(),
  ]);
  let matchedAccountId: string | null = null;
  if (property) {
    // Validée contre MES propriétés vérifiées : je ne peux tamponner mon
    // autorité que sur une propriété que je peux réellement lire. Le compte
    // qui la voit devient le compte épinglé — pas de sélecteur à l'écran.
    const { properties } = await getGscStatusAction();
    const match = properties.find((p) => p.siteUrl === property);
    if (!match) {
      return { ok: false, error: "Propriété GSC non vérifiée ou inconnue." };
    }
    matchedAccountId = match.accountId;
    // Une propriété GSC ne peut être mappée qu'à un seul site, globalement
    // (index unique sites_gsc_property_uidx). On vérifie d'abord pour
    // remonter une erreur métier propre au lieu d'un throw sur la contrainte.
    const [collision] = await db
      .select({ id: sitesTable.id })
      .from(sitesTable)
      .where(
        and(eq(sitesTable.gscProperty, property), ne(sitesTable.id, siteId)),
      );
    if (collision) {
      return {
        ok: false,
        error: "Cette propriété GSC est déjà mappée à un autre site.",
      };
    }
  }
  const updated = await db
    .update(sitesTable)
    .set({
      gscProperty: property,
      // Effacer la propriété efface l'autorité : garder un tampon sans
      // propriété ne veut rien dire. Les deux colonnes bougent ensemble —
      // un utilisateur sans compte épinglé ne résoudrait aucun jeton.
      gscAuthorizedUserId: property ? userId : null,
      gscAuthorizedAccountId: property ? matchedAccountId : null,
    })
    .where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (updated.length === 0) {
    return { ok: false, error: "Site introuvable dans cette organisation." };
  }
  try {
    revalidatePath("/gsc");
  } catch {}
  return { ok: true };
}
```

- [ ] **Step 6: Adapter `syncGscKeywordsAction`**

Remplacer la gate `canManageGsc()` par :

```ts
  if (!(await currentUserCanAccessSite(siteId))) {
    return { ok: false, error: "Accès refusé à ce site." };
  }
```

et remplacer la résolution du jeton + la lecture de `gscProperty` par un seul appel :

```ts
  const resolved = await resolveSiteGscToken(siteId);
  if (!resolved.ok) {
    return { ok: false, error: gscFailureMessage(resolved.reason) };
  }
  const token = resolved.accessToken;
  const property = resolved.property;
```

`site.targetCountries` reste lu depuis la table (la requête existante peut être réduite à ce seul champ). Remplacer ensuite chaque `site.gscProperty` par `property` dans le corps de la fonction.

Ajouter le traducteur de raisons dans un module **séparé**, `src/lib/gsc/failure-message.ts` — pas dans `actions.ts`. Un fichier `"use server"` ne peut exporter que des fonctions async : le transform SWC de Next rejette tout le reste (`Only async functions are allowed to be exported in a "use server" file`). Module pur, sans `server-only`, pour rester importable côté client :

```ts
/** Une raison d'échec → une phrase. Unique endroit où la traduction existe :
 *  worker et UI lisent la même sortie typée, donc ne peuvent pas diverger. */
export function gscFailureMessage(reason: SiteGscFailure): string {
  switch (reason) {
    case "no-property":
      return "Mappe d'abord une propriété GSC pour ce site.";
    case "no-authority":
    case "authority-lost":
      return "Aucun compte Google actif sur ce site — reprends la connexion.";
    case "no-google-account":
      return "Connecte un compte Google Search Console.";
    case "missing-scope":
      return "Le compte Google connecté n'a pas l'accès Search Console — reconnecte-le.";
    case "token-refused":
      return "Google a refusé l'accès — reconnecte le compte.";
  }
}
```

- [ ] **Step 7: Nettoyer les imports**

Supprimer de `actions.ts` les imports de `./owner` (`canLinkGsc`, `canManageGsc`, `getOrgOwnerGscToken`, `isGscOwner`, `orgHasGoogleAccountMissingScope`) et le commentaire mort ligne 19. Ajouter `getActiveUserId` depuis `@/lib/active-org`, `account`/`user` depuis `@/lib/db/auth-schema`, `auth` depuis `@/lib/auth`, `hasWebmastersScope` depuis `./scope`, et les nouveaux modules.

- [ ] **Step 8: Lancer les tests**

Run: `bun run test src/lib/gsc/`
Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add src/lib/gsc/actions.ts src/lib/gsc/actions.test.ts
git commit -m "feat(gsc): gates par accès au site et autorité tamponnée au mapping

Le statut ne parle plus de l'org mais de mon compte Google et des sites
auxquels j'ai accès. Mapper une propriété tamponne l'appelant comme
autorité — c'est aussi le chemin de reprise en main."
```

---

### Task 6: Import et readiness du prompt

Les deux derniers appelants de `getOrgOwnerGscToken`.

**Files:**
- Modify: `src/lib/gsc/import-actions.ts:23,34,49,88,114`
- Modify: `src/lib/keywords/prompt/actions.ts:8,62`
- Modify: `src/lib/keywords/prompt/actions.test.ts:32`

**Interfaces:**
- Consumes: `resolveSiteGscToken`, `deriveSiteGscState` (Task 3), `currentUserCanAccessSite` (Task 1)

- [ ] **Step 1: Lire les deux fichiers avant de modifier**

`import-actions.ts` gate deux fois avec `canManageGsc()` puis résout le jeton par l'org (lignes 49 et 114). `prompt/actions.ts:62` teste seulement `(await getOrgOwnerGscToken(orgId)) !== null` pour un booléen de readiness.

- [ ] **Step 2: Adapter les tests**

Dans `src/lib/keywords/prompt/actions.test.ts`, remplacer le stub ligne 32 :

```ts
// avant : getOrgOwnerGscToken: async () => null
mock.module("@/lib/gsc/site-state", () => ({
  deriveSiteGscState: async () => ({
    state: "no-authority",
    property: null,
    authorityUserId: null,
  }),
}));
```

et supprimer le `mock.module` de `@/lib/gsc/owner` s'il ne sert plus qu'à ça.

- [ ] **Step 3: Modifier `prompt/actions.ts`**

Le readiness est par site, pas par org — c'est une correction au passage, pas un élargissement. Le site est déjà en portée : `row.siteId`, déjà utilisé ligne 53 pour la vérification de visibilité. Remplacer les lignes 57-62 (commentaire compris) par :

```ts
  // Connexion GSC = état de CE site. La carte de complétude distingue
  // « connecté, positions pas encore remontées » de « non connecté ».
  const gscConnected = (await deriveSiteGscState(row.siteId)).state === "ok";
```

Supprimer l'import devenu inutile de `getActiveOrgId` s'il ne sert plus ailleurs dans le fichier.

- [ ] **Step 4: Modifier `import-actions.ts`**

Remplacer les deux gates `canManageGsc()` par `currentUserCanAccessSite(siteId)` (message : `"Accès refusé à ce site."`), et les deux `getOrgOwnerGscToken(orgId)` par `resolveSiteGscToken(siteId)` avec le message issu de `gscFailureMessage`, importé depuis **`@/lib/gsc/failure-message`** (et non depuis `actions.ts`, qui est `"use server"` et ne peut exporter que de l'async).

- [ ] **Step 5: Lancer les tests**

Run: `bun run test src/lib/keywords/prompt/ src/lib/gsc/`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/lib/gsc/import-actions.ts src/lib/keywords/prompt/actions.ts src/lib/keywords/prompt/actions.test.ts
git commit -m "refactor(gsc): import et readiness sur la résolution par site"
```

---

### Task 7: Démanteler `gsc/owner.ts` et supprimer le code mort

Purement mécanique, mais deux consommateurs hors GSC en dépendent : ne pas supprimer avant de déplacer.

**Files:**
- Create: `src/lib/auth/request-headers.ts`
- Create: `src/lib/connectors/access.ts`
- Delete: `src/lib/gsc/owner.ts`, `src/lib/gsc/token.ts`
- Modify: `src/lib/mcp/keys-actions.ts:15`, `src/lib/billing/pack-actions.ts:19`, `src/lib/connectors/core.ts:13,74,103`, `src/lib/connectors/actions.ts:10,28`, `src/lib/seranking/integration-core.ts:16,71,106`
- Modify: `src/lib/mcp/keys-actions.test.ts:24`, `src/lib/billing/pack.test.ts:105`

- [ ] **Step 1: Créer `src/lib/auth/request-headers.ts`**

```ts
// src/lib/auth/request-headers.ts — headers de requête, avec repli hors
// portée de requête (tests). Vivait dans gsc/owner.ts, où il n'avait rien à
// faire : facturation et MCP s'en servent.
import "server-only";
import { headers } from "next/headers";

export async function requestHeaders(): Promise<Headers> {
  try {
    return await headers();
  } catch {
    return new Headers();
  }
}
```

- [ ] **Step 2: Créer `src/lib/connectors/access.ts`**

```ts
// src/lib/connectors/access.ts — qui peut gérer les connecteurs de l'org
// (SE Ranking, Mangools). Owner et admin : ce sont des clés d'API partagées
// par toute l'organisation, pas des accès personnels comme GSC.
import "server-only";
import { requestHeaders } from "@/lib/auth/request-headers";
import { auth } from "@/lib/auth";

export async function canManageOrgConnectors(): Promise<boolean> {
  const m = await auth.api.getActiveMember({ headers: await requestHeaders() });
  return m?.role === "owner" || m?.role === "admin";
}
```

- [ ] **Step 3: Mettre à jour les cinq consommateurs**

- `src/lib/mcp/keys-actions.ts:15` et `src/lib/billing/pack-actions.ts:19` : importer `requestHeaders` depuis `@/lib/auth/request-headers`.
- `src/lib/connectors/core.ts:13`, `src/lib/connectors/actions.ts:10`, `src/lib/seranking/integration-core.ts:16` : importer `canManageOrgConnectors` depuis `@/lib/connectors/access` et remplacer les usages de `canLinkGsc`. Mettre à jour les commentaires qui citent `canLinkGsc` / `gsc:link`.

- [ ] **Step 4: Mettre à jour les stubs de test**

`src/lib/mcp/keys-actions.test.ts:24` et `src/lib/billing/pack.test.ts:105` stubbent `requestHeaders` depuis `@/lib/gsc/owner` — repointer sur `@/lib/auth/request-headers`.

- [ ] **Step 5: Supprimer les modules morts**

```bash
git rm src/lib/gsc/owner.ts src/lib/gsc/token.ts
```

`token.ts` n'a aucun appelant : sa suppression était déjà annoncée dans un commentaire de `actions.ts`, mais le fichier était resté.

- [ ] **Step 6: Vérifier qu'aucune référence ne subsiste**

Run: `grep -rn "gsc/owner\|getGscAccessToken\|canLinkGsc\|canManageGsc\|isGscOwner\|getOrgOwnerGscToken\|orgHasGoogleAccountMissingScope" src`
Expected: aucun résultat.

- [ ] **Step 7: Compiler et tester**

Run: `bunx tsc --noEmit` puis `bun run test src/lib/mcp src/lib/billing src/lib/connectors src/lib/seranking src/lib/gsc`
Expected: exit 0, puis PASS.

- [ ] **Step 8: Commit**

```bash
git add -A src/lib
git commit -m "refactor(gsc): démanteler owner.ts et supprimer token.ts

requestHeaders n'avait rien de GSC (facturation, MCP). canLinkGsc était
mal nommée pour son usage réel : renommée canManageOrgConnectors."
```

---

### Task 8: UI — connexion et propriétés

**Files:**
- Modify: `src/components/gsc/gsc-connection-card.tsx`
- Modify: `src/components/gsc/gsc-properties-card.tsx`
- Modify: `src/app/(app)/gsc/page.tsx:34-35`

**Interfaces:**
- Consumes: `GscStatus`, `GscSiteRow` (Task 5)

Composants shadcn uniquement — pas de Radix en direct. Utiliser le skill `shadcn` pour tout composant à ajouter.

- [ ] **Step 1: Adapter la carte de connexion**

Elle parlait de la connexion de l'org ; elle parle maintenant de **mon** compte Google. Piloter sur `status.myGoogle` :

- `"none"` → « Aucun compte Google connecté » + bouton de liaison (le flux `linkSocial` existant, inchangé)
- `"missing-scope"` → « Compte Google connecté, mais sans l'accès Search Console » + bouton de reconnexion
- `"linked"` → « Compte Google connecté » + nombre de propriétés vérifiées

Supprimer toute copie mentionnant le propriétaire de l'organisation (`isOwner`, « connecté par le propriétaire ») : ces champs n'existent plus dans `GscStatus`.

- [ ] **Step 2: Adapter la carte des propriétés**

Une ligne par site de `status.sites`. Chaque ligne affiche : nom du site, sélecteur de propriété (alimenté par `status.properties`, donc *mes* propriétés), l'état, et l'autorité.

**Pas de sélecteur de compte Google.** `status.properties` agrège les propriétés de tous mes comptes Google porteurs du scope, chacune portant son `accountId` : l'utilisateur choisit une propriété, le compte qui la voit s'en déduit. C'est le geste naturel, et il produit le bon modèle sans rien demander de plus. N'ajoute pas d'écran de choix de compte.

Copie de l'autorité :
- `isMine === true` → « Connecté par toi »
- `authorityName` non nul → « Connecté par {authorityName} »
- sinon rien

Copie de l'état, une par valeur de `state` :
- `"ok"` → pas de bandeau
- `"no-property"` → « Aucune propriété mappée »
- `"no-authority"` / `"authority-lost"` → « À reconnecter » + bouton **Reprendre**
- `"no-google-account"` / `"missing-scope"` → « Reconnecte ton compte Google »

Le bouton **Reprendre** appelle `setSiteGscPropertyAction(site.id, site.gscProperty)` — la même action que le sélecteur. Il est désactivé si `status.myGoogle !== "linked"` (on ne peut pas devenir autorité sans compte Google utilisable) ; le titre au survol explique pourquoi.

- [ ] **Step 3: Adapter la page**

`src/app/(app)/gsc/page.tsx` passe déjà `status` aux deux cartes ; vérifier que les props supprimées (`isOwner`, `canManage`, `canLink`) ne sont plus référencées, et que `GscSnapshotHealthCard` (super-admin) est inchangée.

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

Run: `bunx tsc --noEmit` puis `bunx biome check src/components/gsc src/app/\(app\)/gsc`
Expected: exit 0 pour les deux.

- [ ] **Step 5: Vérifier dans le navigateur**

Lancer l'app et ouvrir `/gsc` avec le compte de démo (`bastien@elevaseo.com`, org `test5`). Vérifier : la carte de connexion reflète l'état du compte courant, chaque site affiche son état, le sélecteur liste les propriétés du compte courant.

- [ ] **Step 6: Commit**

```bash
git add src/components/gsc src/app/\(app\)/gsc
git commit -m "feat(gsc): UI de connexion par compte et d'état par site"
```

---

### Task 9: Dérivation de l'activation par site

**Files:**
- Modify: `src/lib/onboarding/activation/derive.ts`
- Modify: `src/lib/onboarding/activation/actions.ts:24`
- Modify: `src/lib/onboarding/activation/derive.test.ts`

**Interfaces:**
- Produces: `deriveActivationFlags(orgId: string, siteId: string | null): Promise<ActivationFlags>`

C'est la correction du bug relevé pendant la conception : dérivée par organisation, la checklist arrive déjà cochée sur le deuxième site, et le coéquipier ne voit jamais le tutoriel.

- [ ] **Step 1: Ajouter le test d'isolation par site**

Dans `derive.test.ts`, ajouter un second site à la fixture et vérifier qu'il a ses propres drapeaux :

```ts
test.skipIf(!RUN_DB)("un second site a ses propres drapeaux", async () => {
  // SITE_A a des mots-clés et une propriété GSC ; SITE_B vient d'être créé.
  const a = await derive.deriveActivationFlags(ORG, SITE_A);
  const b = await derive.deriveActivationFlags(ORG, SITE_B);
  expect(a["import-keywords"]).toBe(true);
  expect(b["import-keywords"]).toBe(false);
  expect(a["connect-gsc"]).toBe(true);
  expect(b["connect-gsc"]).toBe(false);
  // invite-teammate reste par organisation : les deux sites voient pareil.
  expect(a["invite-teammate"]).toBe(b["invite-teammate"]);
});

test.skipIf(!RUN_DB)("sans site actif, add-site est faux", async () => {
  const f = await derive.deriveActivationFlags(ORG, null);
  expect(f["add-site"]).toBe(false);
});
```

Adapter les appels existants du fichier pour passer le second argument.

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/onboarding/activation/derive.test.ts`
Expected: FAIL — la signature n'accepte qu'un argument.

- [ ] **Step 3: Modifier `derive.ts`**

Signature `deriveActivationFlags(orgId: string, siteId: string | null)`. Portée de chaque drapeau :

- `add-site` : `siteId !== null` — plus aucune requête
- `connect-gsc` : ce site a `gscProperty IS NOT NULL` **et** `gscAuthorizedUserId IS NOT NULL`
- `import-keywords` : mots-clés de **ce** site (`eq(keywords.siteId, siteId)`, la jointure sur `sites` devient inutile)
- `first-enrichment` : idem, plus le filtre `enrichmentExports != '{}'::jsonb`
- `invite-teammate` : inchangé, par organisation

Quand `siteId` est `null`, les quatre premiers sont faux sans requête.

- [ ] **Step 4: Modifier l'appelant**

Dans `activation/actions.ts`, résoudre le site actif avant de dériver :

```ts
    const sites = await getSitesForActiveOrg();
    const siteId = sites.length
      ? await getActiveSiteId(sites.map((s) => s.id))
      : null;
    return {
      flags: await deriveActivationFlags(orgId, siteId),
      dismissed: false,
    };
```

`getSitesForActiveOrg` filtre déjà par accès : un member ne dérivera jamais sur un site qu'il ne voit pas. Importer `getSitesForActiveOrg` depuis `@/lib/sites/access` et `getActiveSiteId` depuis `@/lib/sites/active-site`.

L'écartement de la carte reste clé sur `(userId, orgId)` — décision explicite de la spec, ne pas la changer.

- [ ] **Step 5: Lancer les tests**

Run: `bun run test src/lib/onboarding/`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/lib/onboarding/activation
git commit -m "fix(onboarding): dériver la checklist d'activation par site

Dérivée par organisation, elle arrivait déjà cochée sur le deuxième site
et le coéquipier ne voyait jamais le tutoriel. invite-teammate reste par
organisation : inviter n'est pas une action de site."
```

---

### Task 10: Portes de sortie

**Files:**
- Modify: `src/lib/security/tenancy-guard.ts` (si les nouveaux fichiers `"use server"` doivent y être déclarés)
- Modify: `src/i18n/untranslated-allowlist.ts` (si de nouveaux libellés non traduits apparaissent)

- [ ] **Step 0: Corriger les références périmées relevées pendant la branche**

Quatre endroits décrivent encore le modèle par organisation ou citent des symboles supprimés. Ils ont été laissés en place tâche après tâche pour ne pas élargir les périmètres ; c'est ici qu'on les solde.

- `src/lib/auth/impersonation-blocked-paths.ts` (vers les lignes 69 et 72) — cite `worker-token`, supprimé en tâche 4.
- `src/lib/db/domain-schema.ts` (vers la ligne 114) — renvoie à `src/lib/gsc/owner.ts`, supprimé en tâche 7, et décrit le modèle org-level que cette branche remplace.
- `docs/project/hierarchie-permissions.md` (vers la ligne 284) — annonce « Search Console : mutations — `canManageGsc()` — owner | admin ». Faux : `canManageGsc` n'existe plus, et un `member` ayant accès au site peut désormais mapper une propriété et lancer un sync. C'est l'intention du design, le relevé doit suivre.
- `docs/project/hierarchie-permissions.md` (vers la ligne 304) — documente la divergence « owner strict » du worker, qui n'existe plus.

Corrige le texte pour décrire l'état réel après cette branche. Ne réécris pas ces documents au-delà de ces passages.

- [ ] **Step 1: Vérifier le backstop de tenancy**

Run: `bun run test src/lib/security/`
Expected: PASS. En cas d'échec, déclarer les nouveaux fichiers `"use server"` dans `tenancy-guard.ts` en suivant le format des entrées existantes.

- [ ] **Step 2: Chercher le code mort restant**

Run: `bunx knip`
Expected: aucun nouvel export ou fichier non utilisé introduit par la branche. Comparer avec la sortie sur `dev` en cas de doute.

- [ ] **Step 3: Établir la référence de tests sur `dev`**

Avant de juger la branche, mesurer la base — le projet a des échecs pré-existants.

```bash
git worktree add /tmp/gsc-baseline dev
cd /tmp/gsc-baseline && bun install && bun run test:all 2>&1 | grep -E "^\(fail\)" | sort > /tmp/baseline-failures.txt
```

- [ ] **Step 4: Lancer la suite complète sur la branche et comparer**

```bash
cd /Users/humperdoo/Github/seo-insight
bun run test:all 2>&1 | grep -E "^\(fail\)" | sort > /tmp/branch-failures.txt
diff /tmp/baseline-failures.txt /tmp/branch-failures.txt
```

Expected: `diff` vide — aucune régression. Tout écart doit être corrigé, pas expliqué.

Attention : lancer un agent en worktree déplace le CWD du shell contrôleur. Revenir explicitement dans le dépôt principal avant de comparer.

- [ ] **Step 5: Compiler et construire**

Run: `bunx tsc --noEmit` puis `bun run build`
Expected: exit 0, build réussi.

- [ ] **Step 6: Lint des fichiers touchés**

```bash
bunx biome check $(git diff --name-only dev...HEAD | grep -E '\.(ts|tsx)$')
```

Expected: propre. La base a ~288 erreurs pré-existantes hors périmètre — ne pas les corriger ici.

- [ ] **Step 7: Nettoyer le worktree de référence**

```bash
git worktree remove /tmp/gsc-baseline
```

- [ ] **Step 8: Commit final si nécessaire**

```bash
git add -A
git commit -m "chore(gsc): portes de sortie de la branche"
```

---

## Ce que ce plan ne fait pas

- **Pas de hooks de nettoyage** (`afterRemoveMember`, `afterUpdateMemberRole`). La revalidation à l'usage rend la correction complète sans eux ; les hooks n'apporteraient qu'un affichage plus prompt de l'état « à reconnecter ». À traiter séparément si le besoin se confirme.
- **Pas de correction du scope `webmasters` complet** : quelqu'un qui a accordé le scope en écriture reste considéré comme non connecté, comme aujourd'hui. Parité voulue, backlog séparé.
- **Pas de blocage des écritures d'organisation et de facturation sous impersonation** : demande une décision produit préalable.
