# Octroi d'accès à la création de site + garde d'impersonation — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rendre le modèle d'accès aux sites honnête en enregistrant le droit du créateur, et empêcher qu'une session d'impersonation modifie durablement le compte de la personne impersonnée.

**Architecture:** Deux chantiers indépendants. Le premier ajoute une insertion dans une transaction existante. Le second extrait un helper `isImpersonating()` — aujourd'hui dupliqué trois fois — et l'applique en tête des Server Actions du dépôt qui écrivent une donnée clée sur la session.

**Tech Stack:** Next 16 App Router, Better Auth (plugins organization + admin), Drizzle/Postgres, bun.

**Branche :** `fix/site-access-and-impersonation`, créée depuis `dev`.

**Référence :** `docs/project/hierarchie-permissions.md` (état de l'existant, relevé du 25/07).

## Global Constraints

- **Package manager : bun.** Jamais `npm`/`npx` (`EOVERRIDE` sur ce repo). Binaire ponctuel : `bunx --bun <cmd>`.
- **Aucun import direct de `radix-ui`** dans le nouveau code.
- **i18n** : toute clé ajoutée doit exister dans `messages/en.json` ET `messages/fr.json`. Ces deux locales seulement.
- **Tests DB** : `describe.skipIf(!RUN_DB)` avec `RUN_DB = process.env.DB_TESTS === "1"`, `mock.module("server-only", () => ({}))` en tête, imports dynamiques dans `beforeEach`, `await assertTestDb()` **avant** tout `delete`, jamais de `db.end()`.
- **Préfixe de fixtures unique par fichier de test** (les fichiers d'un répertoire partagent un process bun).
- **`getOrgQuotaLimits` se résout AVANT `db.transaction`** — deadlock de pool documenté dans `quotas.ts`.
- **Un module `'use server'` n'expose que des wrappers authentifiés** ; les fonctions privilégiées vivent dans un module sans la directive. Un test de surface verrouille la liste des exports.
- **Conventional commits.** Jamais de commit sur `main`.

---

### Task 1 : `createSiteAction` enregistre le droit de son créateur

Aujourd'hui la création de site n'écrit aucune ligne `site_access` (vérifié : le fichier n'importe même pas la table). Sans conséquence tant que seuls owners et admins créent des sites, puisqu'ils contournent `site_access` par leur rôle — mais le modèle est malhonnête : **un owner rétrogradé en membre perd l'accès aux sites qu'il a lui-même créés.** Et si la création s'ouvre un jour aux membres, le créateur perdrait immédiatement l'accès à sa propre création.

**Files:**
- Modify: `src/lib/sites/actions.ts`
- Create: `src/lib/sites/create-site-access.test.ts`

**Interfaces:**
- Consumes: `siteAccess` depuis `@/lib/db/domain-schema` ; `getActiveUserId()` déjà résolu dans `ownerUserId`.
- Produces: aucun nouvel export. Le contrat de `createSiteAction` est inchangé.

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

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

```ts
import { beforeEach, describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("createSiteAction grants site_access (postgres)", () => {
  const ORG = "csa-org-1";
  const USER = "csa-user-1";

  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques
  let db: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques
  let authSchema: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques
  let domain: any;
  let createSiteAction: (raw: {
    name: string;
    url: string;
    primaryLanguage: string;
    languages?: string[];
    targetCountries?: string[];
  }) => Promise<{ ok: boolean; site?: { id: string } }>;

  async function cleanup() {
    const { eq } = await import("drizzle-orm");
    await db.delete(authSchema.organization).where(eq(authSchema.organization.id, ORG));
    await db.delete(authSchema.user).where(eq(authSchema.user.id, USER));
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    authSchema = await import("@/lib/db/auth-schema");
    domain = await import("@/lib/db/domain-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();

    await cleanup();
    await db.insert(authSchema.user).values({
      id: USER,
      name: "Creator",
      email: "csa-creator@example.test",
      emailVerified: true,
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    await db.insert(authSchema.organization).values({
      id: ORG,
      name: "CSA Org",
      slug: "csa-org-slug",
      createdAt: new Date(),
    });
    await db.insert(authSchema.member).values({
      id: "csa-member-1",
      organizationId: ORG,
      userId: USER,
      role: "owner",
      createdAt: new Date(),
    });

    const access = await import("./access");
    access.__setTestCaller({ userId: USER, orgId: ORG, role: "owner" });
    ({ createSiteAction } = await import("./actions"));
  });

  it("insère une ligne site_access pour le créateur, dans la même transaction", async () => {
    const res = await createSiteAction({
      name: "CSA Site",
      url: "https://csa.example",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: ["FR"],
    });
    expect(res.ok).toBe(true);
    const siteId = res.site?.id as string;

    const { and, eq } = await import("drizzle-orm");
    const rows = await db
      .select({ userId: domain.siteAccess.userId })
      .from(domain.siteAccess)
      .where(
        and(eq(domain.siteAccess.siteId, siteId), eq(domain.siteAccess.userId, USER)),
      );
    expect(rows).toHaveLength(1);
  });
});
```

Vérifier avant d'écrire que `getActiveOrgId`/`getActiveUserId` sont bien satisfaits par le seam `__setTestCaller` ; si le test échoue pour une raison d'environnement plutôt que d'assertion, adapter le harnais en s'inspirant de `src/lib/sites/actions.test.ts` et le signaler.

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

Run: `bun run db:migrate:test && bun run test src/lib/sites/create-site-access.test.ts`
Expected: FAIL — `expect(rows).toHaveLength(1)` reçoit `0`.

- [ ] **Step 3: Écrire l'implémentation minimale**

Dans `src/lib/sites/actions.ts`, ajouter `siteAccess as siteAccessTable` à l'import depuis `@/lib/db/domain-schema`, puis insérer, **immédiatement après** `tx.insert(sitesTable).values({...})` et avant le fan-out des backlinks :

```ts
      // Le créateur garde un droit explicite sur son site. Owners et admins
      // contournent site_access par leur rôle, mais l'enregistrer rend le
      // modèle honnête : une rétrogradation en member ne doit pas faire
      // perdre l'accès à un site qu'on a soi-même créé.
      await tx.insert(siteAccessTable).values({ siteId: id, userId: ownerUserId });
```

L'ordre importe : la clé étrangère de `site_access` référence `sites.id`, l'insertion du site doit précéder.

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

Run: `bun run test src/lib/sites/create-site-access.test.ts`
Expected: PASS.

- [ ] **Step 5: Vérifier l'absence de régression**

Run: `bun run test src/lib/sites`
Expected: l'échec préexistant `createSiteAction activates the backlink reservoir` peut rester ; **aucun autre**. Le confirmer en comparant à un run sur l'arbre stashé.

Run: `bunx --bun tsc --noEmit`
Expected: exit 0.

- [ ] **Step 6: Commit**

```bash
git add src/lib/sites/actions.ts src/lib/sites/create-site-access.test.ts
git commit -m "fix(sites): enregistrer le droit du createur a la creation du site

createSiteAction n'ecrivait aucune ligne site_access. Sans effet tant
que seuls owners et admins creent des sites (ils contournent par leur
role), mais un owner retrograde en member perdait l'acces aux sites
qu'il avait crees. Insertion dans la transaction existante."
```

---

### Task 2 : un helper `isImpersonating()` et sa garde sur nos Server Actions

Pendant une impersonation, Better Auth remplace `session.user` par la personne cible. **Toute écriture clée sur `session.user.id` atterrit donc sur son compte.** Le relevé a confirmé que c'est déjà le cas en production : un opérateur qui change la langue de l'interface réécrit durablement `user.locale` du client.

Le détecteur est aujourd'hui dupliqué trois fois avec le même cast, et aucune écriture n'est gardée.

**Files:**
- Create: `src/lib/auth/impersonation-guard.ts`
- Create: `src/lib/auth/impersonation-guard.test.ts`
- Modify: `src/i18n/locale-actions.ts`
- Modify: `src/lib/org/active-org-action.ts`
- Modify: `src/lib/onboarding/actions.ts`
- Modify: `src/lib/onboarding/activation/actions.ts`
- Modify: `src/lib/account/password-actions.ts`
- Modify: `src/lib/mcp/keys-actions.ts`
- Modify: `src/lib/sites/site-scoped-gate.tsx` (consomme le helper au lieu de son cast local)

**Interfaces:**
- Produces: `isImpersonating(): Promise<boolean>` — `server-only`, dédupé par requête.

- [ ] **Step 1: Écrire le test du helper**

Créer `src/lib/auth/impersonation-guard.test.ts` :

```ts
import { describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

describe("isImpersonating", () => {
  it("est vrai quand la session porte impersonatedBy", async () => {
    mock.module("next/headers", () => ({ headers: async () => new Headers() }));
    mock.module("@/lib/auth", () => ({
      auth: {
        api: {
          getSession: async () => ({
            user: { id: "u1" },
            session: { impersonatedBy: "admin-1" },
          }),
        },
      },
    }));
    const { isImpersonating } = await import("./impersonation-guard");
    expect(await isImpersonating()).toBe(true);
  });

  it("est faux pour une session normale", async () => {
    mock.module("next/headers", () => ({ headers: async () => new Headers() }));
    mock.module("@/lib/auth", () => ({
      auth: {
        api: {
          getSession: async () => ({ user: { id: "u1" }, session: {} }),
        },
      },
    }));
    const { isImpersonating } = await import("./impersonation-guard");
    expect(await isImpersonating()).toBe(false);
  });

  it("est faux quand il n'y a pas de session", async () => {
    mock.module("next/headers", () => ({ headers: async () => new Headers() }));
    mock.module("@/lib/auth", () => ({
      auth: { api: { getSession: async () => null } },
    }));
    const { isImpersonating } = await import("./impersonation-guard");
    expect(await isImpersonating()).toBe(false);
  });
});
```

Ce fichier introduit un `mock.module` de `@/lib/auth` dans `src/lib/auth/`. Vérifier qu'aucun test voisin de ce répertoire n'en dépend autrement — le motif fuit entre fichiers d'un même process. Si un conflit apparaît, isoler ce test dans son propre répertoire et le dire.

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

Run: `bun run test src/lib/auth/impersonation-guard.test.ts`
Expected: FAIL — `Cannot find module './impersonation-guard'`.

- [ ] **Step 3: Écrire le helper**

Créer `src/lib/auth/impersonation-guard.ts` :

```ts
import "server-only";
import { headers } from "next/headers";
import { cache } from "react";
import { auth } from "@/lib/auth";

/**
 * Pendant une impersonation, Better Auth remplace `session.user` par la
 * personne cible : toute écriture clée sur `session.user.id` atterrit donc sur
 * SON compte. Les Server Actions qui persistent une préférence ou un état
 * utilisateur doivent sauter leur écriture DB quand ceci est vrai.
 *
 * Les effets propres au navigateur de l'opérateur (cookies, état de session)
 * restent légitimes : c'est la persistance en base qui ne l'est pas.
 */
export const isImpersonating = cache(async (): Promise<boolean> => {
  try {
    const session = await auth.api.getSession({ headers: await headers() });
    return Boolean(
      (session?.session as { impersonatedBy?: string | null } | undefined)
        ?.impersonatedBy,
    );
  } catch {
    // Hors contexte de requête : pas d'impersonation possible.
    return false;
  }
});
```

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

Run: `bun run test src/lib/auth/impersonation-guard.test.ts`
Expected: PASS — 3 tests.

- [ ] **Step 5: Bloquer durement la pose de mot de passe**

C'est le cas le plus grave du relevé : `setInitialPasswordAction` ne demande pas l'ancien mot de passe (par conception, pour les comptes magic-link). Pendant une impersonation, elle poserait un mot de passe permanent sur le compte du client, **sans laisser aucune trace d'audit** — les gardes SYS échouent pendant l'impersonation, donc `recordAdminAction` n'est jamais atteint.

Dans `src/lib/account/password-actions.ts`, en tête de `setInitialPasswordAction`, après la vérification de session et avant tout appel à `auth.api.setPassword` :

```ts
  if (await isImpersonating()) {
    return { ok: false, error: "impersonation_forbidden" };
  }
```

Adapter à la forme de retour réelle de la fonction — la lire avant d'écrire. Si elle `throw` au lieu de renvoyer une union, `throw new Error("Forbidden: not allowed while impersonating")`.

- [ ] **Step 6: Sauter les écritures de préférence**

Pour chacune des actions suivantes, sauter **uniquement l'écriture en base**, en conservant les effets propres au navigateur de l'opérateur.

`src/i18n/locale-actions.ts` — conserver le `cookies().set` (la langue de l'onglet de l'opérateur est légitime), sauter le `db.update` :

```ts
  const session = await auth.api.getSession({ headers: await headers() });
  if (session?.user?.id && !(await isImpersonating())) {
    await db.update(userTable).set({ locale }).where(eq(userTable.id, session.user.id));
  }
```

`src/lib/org/active-org-action.ts` — `persistActiveOrgAction` écrit `user.lastActiveOrgId`, ce qui changerait l'organisation par défaut du client à tous ses prochains logins. Sauter l'écriture, retourner sans erreur.

`src/lib/onboarding/actions.ts` — `completeOnboarding` stampe `onboardingCompletedAt`. Sauter l'écriture.

`src/lib/onboarding/activation/actions.ts` — `dismissActivationAction` et `restoreActivationAction` écrivent `activation_dismissals`, clé `(userId, orgId)`. Sauter les deux.

`src/lib/mcp/keys-actions.ts` — `createApiKeyAction` attribuerait une clé API à la cible, consommant son quota. Bloquer plutôt que sauter silencieusement : renvoyer une erreur explicite, en suivant la forme de retour existante de l'action.

**Ne pas toucher** à `revokeApiKeyAction` : révoquer une clé de la cible est un geste de support légitime, l'action est déjà scopée sur son `userId`.

- [ ] **Step 7: Dédupliquer le détecteur existant**

Dans `src/lib/sites/site-scoped-gate.tsx`, remplacer le cast local sur `impersonatedBy` par un appel à `isImpersonating()`. Ne pas modifier `src/app/(app)/layout.tsx` dans cette tâche : il lit déjà la session pour d'autres raisons et un second appel n'y apporterait rien.

- [ ] **Step 8: Vérifier**

Run: `bun run test src/lib/auth && bun run test src/lib/onboarding && bun run test src/lib/sites`
Expected: PASS, aux échecs préexistants près.

Run: `bunx --bun tsc --noEmit`
Expected: exit 0.

- [ ] **Step 9: Commit**

```bash
git add src/lib/auth/impersonation-guard.ts src/lib/auth/impersonation-guard.test.ts src/i18n/locale-actions.ts src/lib/org/active-org-action.ts src/lib/onboarding/actions.ts src/lib/onboarding/activation/actions.ts src/lib/account/password-actions.ts src/lib/mcp/keys-actions.ts src/lib/sites/site-scoped-gate.tsx
git commit -m "fix(auth): ne plus ecrire sur le compte impersonne

Better Auth remplace session.user par la cible pendant une
impersonation : toute ecriture clee sur session.user.id atterrissait sur
son compte. Verifie en production sur setUserLocale, qui reecrivait
durablement la langue du client.

Helper isImpersonating() partage (le detecteur etait duplique trois
fois), pose de mot de passe et creation de cle API bloquees, ecritures
de preference sautees en conservant les effets navigateur."
```

---

### Task 3 : intercepter les endpoints Better Auth appelés depuis le client

Les actions de la Task 2 sont les nôtres : un appel en tête suffit. Mais une grande partie des écritures sur le compte passe par `authClient.*`, qui atteint `/api/auth/*` **sans traverser notre code**. Pendant une impersonation, ces endpoints agissent sur la session vivante — celle de la personne cible.

Le plus grave n'est pas l'escalade de privilège : déclencher une impersonation exige déjà Admin SYS avec 2FA, et cette personne peut de toute façon réinitialiser la 2FA ou changer l'email depuis `/admin`. Le problème est **le contournement de la journalisation** : les gardes SYS échouent pendant une impersonation (`isCurrentUserSuperAdmin()` lit l'email de la cible), donc `recordAdminAction` n'est jamais atteint. Ces écritures ne laissent **aucune trace d'audit**.

**Files:**
- Create: `src/lib/auth/impersonation-blocked-paths.ts`
- Create: `src/lib/auth/impersonation-blocked-paths.test.ts`
- Modify: `src/lib/auth.ts` (bloc `hooks.before`)

**Interfaces:**
- Consumes: `getSessionFromCtx`, `APIError`, `createAuthMiddleware` — déjà importés dans `auth.ts`.
- Produces: `IMPERSONATION_BLOCKED_PATHS: ReadonlySet<string>`, `isBlockedDuringImpersonation(path: string): boolean`.

- [ ] **Step 1: Écrire le test de la liste**

La liste est de la donnée : elle se teste nue, sans session ni serveur. Créer `src/lib/auth/impersonation-blocked-paths.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import {
  IMPERSONATION_BLOCKED_PATHS,
  isBlockedDuringImpersonation,
} from "./impersonation-blocked-paths";

describe("isBlockedDuringImpersonation", () => {
  it("bloque les écritures de compte", () => {
    for (const p of [
      "/update-user",
      "/change-email",
      "/change-password",
      "/set-password",
      "/delete-user",
      "/link-social",
      "/unlink-account",
      "/revoke-session",
      "/revoke-sessions",
      "/revoke-other-sessions",
    ]) {
      expect(isBlockedDuringImpersonation(p)).toBe(true);
    }
  });

  it("laisse SORTIR de l'impersonation", () => {
    // Sans ça, l'opérateur reste enfermé dans la session de la cible.
    expect(isBlockedDuringImpersonation("/admin/stop-impersonating")).toBe(false);
  });

  it("ne bloque pas la lecture de session ni la déconnexion", () => {
    expect(isBlockedDuringImpersonation("/get-session")).toBe(false);
    expect(isBlockedDuringImpersonation("/sign-out")).toBe(false);
  });

  it("compare en correspondance exacte, jamais par préfixe", () => {
    // Un préfixe attraperait /admin/stop-impersonating au passage.
    expect(isBlockedDuringImpersonation("/update-user-preferences")).toBe(false);
    expect(isBlockedDuringImpersonation("/admin/update-user")).toBe(false);
  });

  it("n'inclut aucun chemin sous /admin/", () => {
    for (const p of IMPERSONATION_BLOCKED_PATHS) {
      expect(p.startsWith("/admin/")).toBe(false);
    }
  });
});
```

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

Run: `bun run test src/lib/auth/impersonation-blocked-paths.test.ts`
Expected: FAIL — `Cannot find module './impersonation-blocked-paths'`.

- [ ] **Step 3: Écrire la liste**

Créer `src/lib/auth/impersonation-blocked-paths.ts` :

```ts
/**
 * Endpoints Better Auth interdits pendant une impersonation.
 *
 * Ces routes sont appelées directement par `authClient.*` et n'entrent jamais
 * dans nos Server Actions : la seule interception possible est `hooks.before`.
 * Pendant une impersonation la session vivante est celle de la personne cible,
 * donc chacune de ces écritures modifierait SON compte — et sans laisser de
 * trace, puisque les gardes SYS échouent dans ce contexte et que
 * `recordAdminAction` n'est donc jamais atteint.
 *
 * Correspondance EXACTE, jamais par préfixe : `/admin/stop-impersonating` doit
 * rester joignable, sinon l'opérateur est enfermé dans la session de la cible.
 */
export const IMPERSONATION_BLOCKED_PATHS: ReadonlySet<string> = new Set([
  "/update-user",
  "/change-email",
  "/change-password",
  "/set-password",
  "/delete-user",
  "/link-social",
  "/unlink-account",
  "/revoke-session",
  "/revoke-sessions",
  "/revoke-other-sessions",
]);

export function isBlockedDuringImpersonation(path: string): boolean {
  return IMPERSONATION_BLOCKED_PATHS.has(path);
}
```

Avant de figer la liste, vérifier que chaque chemin existe réellement dans la version installée :

```bash
bunx --bun rg -o 'createAuthEndpoint\("/[a-z-]+"' node_modules/better-auth/dist/api/routes/*.mjs | sort -u
```

Vérifier également si le plugin `twoFactor` expose des routes d'activation/désactivation (`bunx --bun rg -o 'createAuthEndpoint\("/two-factor/[a-z-]+"' node_modules/better-auth/dist/plugins/two-factor/*.mjs`). Si oui, les ajouter à la liste et au test — activer ou désactiver la 2FA du compte d'un client est du même ordre. Si un chemin du brief n'existe pas dans la version installée, **le retirer et le dire** plutôt que de garder une entrée morte.

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

Run: `bun run test src/lib/auth/impersonation-blocked-paths.test.ts`
Expected: PASS — 5 tests.

- [ ] **Step 5: Brancher l'interception**

Dans `src/lib/auth.ts`, à l'intérieur du `before: createAuthMiddleware(async (ctx) => { ... })` existant, **en tout premier**, avant le bloc `/organization/invite-member` :

```ts
      // Impersonation : la session vivante est celle de la cible, donc toute
      // écriture de compte atterrirait sur SON profil — et hors de tout audit,
      // les gardes SYS échouant dans ce contexte. Placé en tête pour couper
      // avant toute autre logique. La sortie (/admin/stop-impersonating) n'est
      // volontairement pas dans la liste.
      if (isBlockedDuringImpersonation(ctx.path)) {
        const session = await getSessionFromCtx(ctx);
        const impersonatedBy = (
          session?.session as { impersonatedBy?: string | null } | undefined
        )?.impersonatedBy;
        if (impersonatedBy) {
          // English on purpose: server hooks have no next-intl request scope.
          throw new APIError("FORBIDDEN", {
            message: "Not allowed while impersonating a user.",
          });
        }
      }
```

`getSessionFromCtx`, `APIError` et `createAuthMiddleware` sont déjà importés dans ce fichier — vérifier plutôt que de ré-importer.

Le coût est nul pour un utilisateur normal : la session n'est lue **que** si le chemin figure dans la liste, ce qui ne concerne que des mutations de compte, déjà rares et déjà coûteuses.

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

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

Run: `bunx --bun tsc --noEmit`
Expected: exit 0.

- [ ] **Step 7: Vérification manuelle du chemin de sortie**

C'est la vérification qui compte le plus dans cette tâche : **un opérateur enfermé dans une impersonation serait un incident bien pire que le problème corrigé.**

Avec d3k (`d3k status --json` d'abord, réutiliser le runtime existant), en super-admin : impersonate un compte de test depuis `/admin/users`, vérifier que la bannière s'affiche, puis cliquer « Stop » et vérifier le retour sur `/admin/users` avec l'identité d'origine restaurée. Tenter ensuite un changement de nom depuis `/settings/account` pendant une impersonation et vérifier qu'il est refusé.

Si l'environnement ne permet pas d'atteindre ce parcours, **le dire franchement** plutôt que de le contourner — mais tenter réellement avant d'abandonner.

- [ ] **Step 8: Commit**

```bash
git add src/lib/auth/impersonation-blocked-paths.ts src/lib/auth/impersonation-blocked-paths.test.ts src/lib/auth.ts
git commit -m "fix(auth): interdire les ecritures de compte pendant une impersonation

Les endpoints appeles par authClient n'entrent jamais dans nos Server
Actions : seule hooks.before peut les intercepter. Pendant une
impersonation ils modifieraient le compte de la cible, et sans trace
puisque les gardes SYS echouent dans ce contexte.

Correspondance exacte : /admin/stop-impersonating reste joignable."
```

---

## Hors périmètre — à traiter séparément

**La traçabilité du support.** `createTicketAction` et `postMessageAction` enregistrent aujourd'hui les messages de l'opérateur avec `senderType: "client"` et l'identifiant de la cible, parce que `isCurrentUserSuperAdmin()` renvoie faux pendant une impersonation. Le correctif n'est pas de bloquer — répondre au nom du client peut être voulu — mais d'enregistrer le véritable auteur. Cela demande une colonne.

**Le tutoriel interactif.** La table `tour_progress` n'existe pas encore ; elle arrive avec `2026-07-25-onboarding-tours.md`. Ce plan-là devra consommer `isImpersonating()` et, au-delà de l'écriture, **ne pas déclencher les tours** pendant une impersonation : l'overlay s'interposerait entre l'opérateur et l'élément qu'il vient inspecter.

---

## Fin de branche

- [ ] `bun run test:all` — comparer le nombre d'échecs à un run sur `dev` ; le delta doit être nul.
- [ ] `bun run lint`, `bunx --bun tsc --noEmit`, `bun run build`.
- [ ] Revue de `git diff dev..HEAD`.
- [ ] Merge ff-only dans `dev`, push, suppression de la branche. **Ne pas toucher à `main`.**
