# Partage public d'une discussion Serenia — plan d'implémentation

> **Pour les agents :** SOUS-COMPÉTENCE REQUISE — utiliser `superpowers:subagent-driven-development` pour dérouler ce plan tâche par tâche. Les étapes sont des cases à cocher (`- [ ]`).

**Objectif :** permettre à un utilisateur de générer un lien révocable donnant accès, sans compte, à une discussion Serenia, en lecture seule.

**Architecture :** le dispositif de partage public existe déjà (trait `ShareableTrait`, `ShareLinkService`, `PublicShareGuard`, `PublicShareResponseListener`, modale commune). Ce chantier n'ajoute qu'une entité partageable, une projection en liste blanche, une route publique et une page. La particularité de Serenia est que **les messages sont chiffrés au repos** : la projection doit les déchiffrer sans jamais laisser passer les champs internes.

**Pile technique :** Symfony 7.3, PHP 8.2+, Doctrine, Twig, Stimulus 3, Webpack Encore, PHPUnit 12.

**Spec de référence :** `docs/superpowers/specs/2026-08-01-partage-public-discussion-serenia-design.md`

## Contraintes globales

- **Français** pour les commentaires, messages de commit et vocabulaire métier.
- **Aucune écriture en base depuis une requête anonyme** : pas de débit de tokens, pas de badge, pas d'`ActivityLog`.
- **Le jeton de partage n'est jamais journalisé** — ni Monolog, ni `ActivityLog`, ni message d'exception.
- **404 strictement identique** pour tout échec : jeton inexistant, révoqué, session supprimée.
- **Nom de route en `app_public_*`** — c'est ce préfixe qui déclenche `PublicShareResponseListener` (CSP, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`).
- **`[0-9]` et jamais `\d`** dans toute contrainte de route : Symfony compile avec le modificateur `u`, `\d` matche alors les chiffres Unicode et produit un `TypeError` avant le contrôleur — donc un 500 anonyme échappant au limiteur.
- **La règle `access_control` porte un slash final.** `^/p/serenia/`, jamais `^/p/serenia`.
- Suites : `php bin/phpunit -d memory_limit=512M <chemin> --testdox`. Après modification d'asset ou de template : `npm run build`. **Ne jamais lancer `npm run build` et `npm run watch` en même temps** — ils s'écrasent dans `public/build/` avec des conventions de nommage différentes.

**Sur la forme des tests dans ce plan.** Les tâches 1 à 4 donnent le code de test verbatim ; les tâches 5 et 6 énumèrent les cas à couvrir et désignent un fichier éprouvé à calquer. Ce n'est pas un oubli. Sur le chantier précédent, le code de test écrit à l'avance s'est révélé faux sur les signatures réelles des entités à quatre reprises — un `setUuid()` inexistant, un utilisateur rejeté faute de statut actif, un identifiant d'élément erroné — et chaque fois l'implémenteur a perdu du temps à défaire une prescription inexacte. Là où les entités sont peu connues, **énumérer ce qui doit être prouvé donne un meilleur résultat que dicter du code qui ne compilera pas**. Ce qui n'est jamais négociable, c'est la liste des propriétés à vérifier.

---

## Structure des fichiers

**Créés**

| Fichier | Responsabilité |
|---|---|
| `src/Service/Serenia/PublicSessionProjector.php` | Liste blanche de ce qui est publié, déchiffrement inclus |
| `src/Controller/Serenia/SereniaPublicController.php` | Route `/p/serenia/{token}` |
| `templates/seren_ia/public_show.html.twig` | Page publique, autonome |
| `assets/controllers/seren_ia/public_show_controller.js` | Rendu Markdown des messages côté client |

**Modifiés**

| Fichier | Modification |
|---|---|
| `src/Entity/Serenia/SereniaChatSession.php` | `use ShareableTrait` + `implements ShareableInterface` |
| `src/Repository/Serenia/SereniaChatSessionRepository.php` | `findOneBySharedToken()` |
| `src/Controller/Serenia/SereniaController.php` | Endpoint `POST /session/{uuid}/partage` |
| `config/packages/security.yaml` | Règle `^/p/serenia/` |
| `templates/_molecules/share-link/share-link-modal.html.twig` | Paramètre `withDownload` |
| `templates/seren_ia/index.html.twig` | Bandeau « discussion publique » + inclusion de la modale |
| `templates/seren_ia/_sessions-list.html.twig` | Entrée « Partager », pastille, attributs `data-*` |
| `webpack.config.js` | Entrée `serenia_public` |
| `tests/Functional/Controller/ProtectedRoutesTest.php` | Le nouveau préfixe entre dans le test d'anti-débordement |

---

## Tâche 1 : Rendre la session partageable

**Fichiers :**
- Modifier : `src/Entity/Serenia/SereniaChatSession.php`
- Test : `tests/Unit/Entity/Serenia/SereniaChatSessionShareableTest.php`

**Interfaces :**
- Consomme : `App\Entity\Shared\ShareableInterface`, `App\Entity\Shared\ShareableTrait` (existants).
- Produit : `SereniaChatSession` implémentant `ShareableInterface`.

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

`tests/Unit/Entity/Serenia/SereniaChatSessionShareableTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity\Serenia;

use App\Entity\Serenia\SereniaChatSession;
use App\Entity\Shared\ShareableInterface;
use App\Entity\User;
use PHPUnit\Framework\TestCase;

final class SereniaChatSessionShareableTest extends TestCase
{
    public function testUneSessionNeuveNEstPasPartagee(): void
    {
        $session = new SereniaChatSession(new User());

        self::assertInstanceOf(ShareableInterface::class, $session);
        self::assertFalse($session->isShared());
        self::assertNull($session->getShareToken());
    }

    public function testIsSharedSuitLaPresenceDuJeton(): void
    {
        $session = new SereniaChatSession(new User());

        $session->setShareToken('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6');
        self::assertTrue($session->isShared());

        $session->setShareToken(null);
        self::assertFalse($session->isShared());
    }
}
```

> Le constructeur de `SereniaChatSession` prend un `User` — vérifie sa signature avant d'écrire, et adapte si elle diffère.

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

Lancer : `php bin/phpunit tests/Unit/Entity/Serenia/SereniaChatSessionShareableTest.php --testdox`
Attendu : ÉCHEC — `SereniaChatSession` n'implémente pas `ShareableInterface`.

- [ ] **Étape 3 : appliquer le trait**

Dans `src/Entity/Serenia/SereniaChatSession.php`, ajouter les `use` en tête et la déclaration :

```php
use App\Entity\Shared\ShareableInterface;
use App\Entity\Shared\ShareableTrait;

// …

class SereniaChatSession implements ShareableInterface
{
    use ShareableTrait;
```

> `share_download_enabled` vient du trait et n'a **aucun usage** pour Serenia : la discussion ne se télécharge pas. Le laisser à `false` coûte moins que de fragmenter un trait partagé par trois outils. Documente-le en commentaire à l'endroit du `use`.

- [ ] **Étape 4 : générer et RELIRE la migration**

```bash
php bin/console make:migration
```

**La base de dev est partagée avec d'autres agents travaillant sur une autre branche.** `make:migration` compare les métadonnées des entités au schéma *vivant* : si ces agents ont appliqué des colonnes absentes des entités de cette branche, la migration générée contiendra des `DROP COLUMN` sur leur travail.

Ouvrir le fichier généré dans `migrations/` et **supprimer toute instruction ne portant pas sur `share_token`, `shared_at` ou `share_download_enabled` dans la table `serenia_chat_session`**, dans `up()` comme dans `down()`. Ne jamais exécuter la migration sans l'avoir lue.

```bash
php bin/console doctrine:migrations:migrate --no-interaction
```

- [ ] **Étape 5 : régénérer le schéma de la base de TEST**

Sans cette étape, la colonne n'existe pas côté test et tous les tests fonctionnels des tâches suivantes échoueront sur une colonne manquante.

```bash
php bin/console doctrine:schema:drop --force --env=test --no-interaction
php bin/console doctrine:schema:create --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
```

- [ ] **Étape 6 : lancer le test et vérifier qu'il passe**

Lancer : `php bin/phpunit tests/Unit/Entity/Serenia/SereniaChatSessionShareableTest.php --testdox`
Attendu : SUCCÈS, 2 tests.

- [ ] **Étape 7 : commit**

```bash
git add src/Entity/Serenia/SereniaChatSession.php migrations/ tests/Unit/Entity/Serenia/
git commit -m "feat(serenia): rend les discussions partageables par lien public"
```

---

## Tâche 2 : Résolution d'une session par son jeton

**Fichiers :**
- Modifier : `src/Repository/Serenia/SereniaChatSessionRepository.php`
- Test : `tests/Functional/Repository/Serenia/SereniaChatSessionRepositoryShareTest.php`

**Interfaces :**
- Produit : `SereniaChatSessionRepository::findOneBySharedToken(string $token): ?SereniaChatSession`.

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

`tests/Functional/Repository/Serenia/SereniaChatSessionRepositoryShareTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Repository\Serenia;

use App\Entity\Serenia\SereniaChatSession;
use App\Entity\User;
use App\Repository\Serenia\SereniaChatSessionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class SereniaChatSessionRepositoryShareTest extends KernelTestCase
{
    public function testUnJetonResoutLaBonneSession(): void
    {
        self::bootKernel();
        $em   = static::getContainer()->get(EntityManagerInterface::class);
        $repo = static::getContainer()->get(SereniaChatSessionRepository::class);

        $user = (new User())->setEmail('serenia-share-' . bin2hex(random_bytes(4)) . '@test.io')->setRoles(['ROLE_USER']);
        $user->setPassword(
            static::getContainer()->get(UserPasswordHasherInterface::class)->hashPassword($user, 'motdepasse')
        );
        $em->persist($user);

        $partagee = (new SereniaChatSession($user))->setShareToken(bin2hex(random_bytes(16)));
        $privee   = new SereniaChatSession($user);
        $em->persist($partagee);
        $em->persist($privee);
        $em->flush();

        self::assertSame(
            $partagee->getId(),
            $repo->findOneBySharedToken((string) $partagee->getShareToken())?->getId()
        );
        self::assertNull($repo->findOneBySharedToken('jeton-qui-nexiste-pas-du-tout-000'));
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

Lancer : `php bin/phpunit -d memory_limit=512M tests/Functional/Repository/Serenia/SereniaChatSessionRepositoryShareTest.php --testdox`
Attendu : ÉCHEC — méthode `findOneBySharedToken` inexistante.

- [ ] **Étape 3 : ajouter la méthode**

Dans `src/Repository/Serenia/SereniaChatSessionRepository.php` :

```php
    /**
     * Résout une discussion par son jeton de partage.
     *
     * Le filtre `shareToken IS NOT NULL` est un garde-fou : sans lui, un appel
     * accidentel avec une valeur nulle retournerait la première session non
     * partagée venue.
     */
    public function findOneBySharedToken(string $token): ?SereniaChatSession
    {
        return $this->createQueryBuilder('s')
            ->andWhere('s.shareToken IS NOT NULL')
            ->andWhere('s.shareToken = :token')
            ->setParameter('token', $token)
            ->getQuery()
            ->getOneOrNullResult();
    }
```

- [ ] **Étape 4 : lancer le test et vérifier qu'il passe**

Lancer : `php bin/phpunit -d memory_limit=512M tests/Functional/Repository/Serenia/SereniaChatSessionRepositoryShareTest.php --testdox`
Attendu : SUCCÈS.

- [ ] **Étape 5 : commit**

```bash
git add src/Repository/Serenia/SereniaChatSessionRepository.php tests/Functional/Repository/
git commit -m "feat(serenia): résolution d'une discussion par son jeton de partage"
```

---

## Tâche 3 : Projection publique — la barrière anti-fuite

C'est la tâche la plus sensible du plan. Tout ce qui sort d'ici est public.

**Fichiers :**
- Créer : `src/Service/Serenia/PublicSessionProjector.php`
- Test : `tests/Unit/Service/Serenia/PublicSessionProjectorTest.php`

**Interfaces :**
- Consomme : `App\Service\Security\EncryptionService::decryptIfEncrypted(string): string`.
- Produit : `PublicSessionProjector::project(SereniaChatSession): array` retournant `['title' => string, 'messages' => array<int, array{role: string, content: string, createdAt: string}>]`.

**Le danger propre à cet outil.** `SereniaChatMessage::$extractedFileContent` porte le **texte intégral des fichiers déposés** dans la conversation. Il n'apparaît jamais à l'écran : c'est la charge envoyée au modèle. Un PDF confidentiel analysé par l'utilisateur y vit en entier. C'est l'équivalent Serenia des notes d'intervenant de Slidia.

**Liste blanche, jamais liste noire.** Tout champ ajouté plus tard à `SereniaChatMessage` doit être publié sur décision, pas par défaut.

- [ ] **Étape 1 : écrire les tests qui échouent**

`tests/Unit/Service/Serenia/PublicSessionProjectorTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service\Serenia;

use App\Entity\Serenia\SereniaChatMessage;
use App\Entity\Serenia\SereniaChatSession;
use App\Entity\User;
use App\Service\Security\EncryptionService;
use App\Service\Serenia\PublicSessionProjector;
use PHPUnit\Framework\TestCase;

final class PublicSessionProjectorTest extends TestCase
{
    private function projector(): PublicSessionProjector
    {
        $encryption = $this->createMock(EncryptionService::class);
        // Le projecteur DOIT déchiffrer : le mock renvoie une valeur reconnaissable.
        $encryption->method('decryptIfEncrypted')
            ->willReturnCallback(static fn (string $v): string => str_replace('CHIFFRE:', '', $v));

        return new PublicSessionProjector($encryption);
    }

    private function session(): SereniaChatSession
    {
        return (new SereniaChatSession(new User()))->setTitle('Ma discussion');
    }

    public function testLeContenuEstDechiffre(): void
    {
        $session = $this->session();
        $session->addMessage((new SereniaChatMessage())->setRole('user')->setContent('CHIFFRE:Bonjour'));

        $projection = $this->projector()->project($session);

        self::assertSame('Bonjour', $projection['messages'][0]['content']);
    }

    public function testLeContenuExtraitDesFichiersNEstJamaisPublie(): void
    {
        $session = $this->session();
        $message = (new SereniaChatMessage())
            ->setRole('user')
            ->setContent('CHIFFRE:Analyse ce document')
            ->setExtractedFileContent('SECRET — chiffre d affaires 2026 : 4,2 M EUR');
        $session->addMessage($message);

        $json = json_encode($this->projector()->project($session), JSON_THROW_ON_ERROR);

        self::assertStringNotContainsString('SECRET', $json);
        self::assertStringNotContainsString('4,2 M', $json);
        self::assertArrayNotHasKey('extractedFileContent', $this->projector()->project($session)['messages'][0]);
    }

    public function testLesClesDePremierNiveauSontExhaustivementFigees(): void
    {
        $projection = $this->projector()->project($this->session());

        self::assertSame(['title', 'messages'], array_keys($projection));
    }

    public function testLesClesDUnMessageSontExhaustivementFigees(): void
    {
        $session = $this->session();
        $session->addMessage((new SereniaChatMessage())->setRole('assistant')->setContent('CHIFFRE:Voici'));

        $projection = $this->projector()->project($session);

        self::assertSame(['role', 'content', 'createdAt'], array_keys($projection['messages'][0]));
    }

    public function testAucunReglageNiCompteurNEstExpose(): void
    {
        $session = $this->session();
        $session->setCommunicationStyle('direct')->setWorkMode('expert')->setAiModel('gpt-4.1');
        $session->addMessage(
            (new SereniaChatMessage())->setRole('assistant')->setContent('CHIFFRE:ok')->setTokenCount(4242)
        );

        $json = json_encode($this->projector()->project($session), JSON_THROW_ON_ERROR);

        self::assertStringNotContainsString('4242', $json);
        self::assertStringNotContainsString('gpt-4.1', $json);
        self::assertStringNotContainsString('expert', $json);
    }
}
```

> Les entités n'ont pas forcément d'accesseurs fluides ni de `addMessage()`. **Lis `SereniaChatSession` et `SereniaChatMessage` avant d'écrire**, et adapte la construction des objets à leurs signatures réelles — sans changer ce que les tests vérifient.

- [ ] **Étape 2 : lancer les tests et vérifier qu'ils échouent**

Lancer : `php bin/phpunit tests/Unit/Service/Serenia/PublicSessionProjectorTest.php --testdox`
Attendu : ÉCHEC — `PublicSessionProjector` introuvable.

- [ ] **Étape 3 : écrire le projecteur**

`src/Service/Serenia/PublicSessionProjector.php` :

```php
<?php

declare(strict_types=1);

namespace App\Service\Serenia;

use App\Entity\Serenia\SereniaChatMessage;
use App\Entity\Serenia\SereniaChatSession;
use App\Service\Security\EncryptionService;

/**
 * Réduit une discussion à ce qui est strictement nécessaire à sa lecture publique.
 *
 * LISTE BLANCHE, jamais liste noire : tout champ ajouté plus tard à un message
 * doit être publié sur décision explicite, pas par simple oubli de le retirer.
 *
 * Le champ à ne JAMAIS publier est `extractedFileContent` : il porte le texte
 * intégral des fichiers déposés dans la conversation, invisible à l'écran mais
 * envoyé au modèle. Un document confidentiel analysé par l'utilisateur y vit en
 * entier.
 *
 * Sont également exclus : `embedding` (vecteur interne), `tokenCount` et
 * `modelUsed` (données de facturation), `contextSummary` (mémoire interne
 * reformulée par le modèle), les réglages de style, de mode et de modèle, les
 * uuid (clés des URLs authentifiées, non révocables) et toute donnée du
 * propriétaire.
 */
final class PublicSessionProjector
{
    public function __construct(private readonly EncryptionService $encryption) {}

    /** @return array{title: string, messages: array<int, array{role: string, content: string, createdAt: string}>} */
    public function project(SereniaChatSession $session): array
    {
        $messages = $session->getMessages()->toArray();

        // Ordre chronologique explicite : ne pas dépendre de l'ordre de la
        // collection Doctrine, qui varie selon le mapping et le chargement.
        usort(
            $messages,
            static fn (SereniaChatMessage $a, SereniaChatMessage $b): int
                => $a->getCreatedAt() <=> $b->getCreatedAt()
        );

        return [
            'title'    => (string) ($session->getTitle() ?? 'Discussion'),
            'messages' => array_map(fn (SereniaChatMessage $m): array => [
                'role'      => $m->getRole(),
                'content'   => $this->encryption->decryptIfEncrypted($m->getContent()),
                'createdAt' => $m->getCreatedAt()->format(\DateTimeInterface::ATOM),
            ], $messages),
        ];
    }
}
```

- [ ] **Étape 4 : lancer les tests et vérifier qu'ils passent**

Lancer : `php bin/phpunit tests/Unit/Service/Serenia/PublicSessionProjectorTest.php --testdox`
Attendu : SUCCÈS, 5 tests.

- [ ] **Étape 5 : prouver que les tests mordent**

Ajoute temporairement `'extractedFileContent' => $m->getExtractedFileContent()` à la projection d'un message, relance, constate que `testLeContenuExtraitDesFichiersNEstJamaisPublie` **et** `testLesClesDUnMessageSontExhaustivementFigees` rougissent. Restaure, relance, constate le retour au vert.

Un test de non-fuite qui passerait avant comme après ne prouve rien.

- [ ] **Étape 6 : commit**

```bash
git add src/Service/Serenia/PublicSessionProjector.php tests/Unit/Service/Serenia/
git commit -m "feat(serenia): projection publique en liste blanche d'une discussion

Exclut le contenu extrait des fichiers déposés, les vecteurs, les compteurs
de tokens et les réglages du propriétaire."
```

---

## Tâche 4 : Route et page publiques

**Fichiers :**
- Créer : `src/Controller/Serenia/SereniaPublicController.php`, `templates/seren_ia/public_show.html.twig`, `assets/controllers/seren_ia/public_show_controller.js`
- Modifier : `config/packages/security.yaml`, `webpack.config.js`, `tests/Functional/Controller/ProtectedRoutesTest.php`
- Test : `tests/Functional/Controller/Serenia/SereniaPublicShareTest.php`

**Interfaces :**
- Consomme : `PublicSessionProjector::project()` (T3), `SereniaChatSessionRepository::findOneBySharedToken()` (T2), `App\Service\Shared\Sharing\PublicShareGuard::assertNotFlooding(Request)` et `::registerFailure(Request)`.
- Produit : route `app_public_serenia_show` (`GET /p/serenia/{token}`).

- [ ] **Étape 1 : ouvrir le préfixe dans `security.yaml`**

Dans `config/packages/security.yaml`, à côté des règles `^/p/slidia/` et `^/p/scormia/` existantes, **avant** la règle fourre-tout `- { path: ^/, roles: ROLE_USER }` :

```yaml
        - { path: ^/p/serenia/, roles: PUBLIC_ACCESS }
```

**Le slash final est impératif** — sans lui, la règle déborderait sur tout chemin commençant par `/p/serenia`. Ajoute ce chemin au jeu de données de `ProtectedRoutesTest::testAucuneRegleAccessControlPubliqueNeDebordeSurUnOutilProtege`, qui verrouille déjà la précision des deux autres règles.

- [ ] **Étape 2 : déclarer l'entrée Encore**

Dans `webpack.config.js`, à côté de `slidia_present` et `scormia_player` :

```js
    .addEntry('serenia_public', './assets/controllers/seren_ia/public_show_entry.js')
```

- [ ] **Étape 3 : écrire le test qui échoue**

`tests/Functional/Controller/Serenia/SereniaPublicShareTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller\Serenia;

use App\Entity\Serenia\SereniaChatMessage;
use App\Entity\Serenia\SereniaChatSession;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class SereniaPublicShareTest extends WebTestCase
{
    private KernelBrowser $client;

    protected function setUp(): void
    {
        static::ensureKernelShutdown();
        $this->client = static::createClient();
    }

    public function testUnVisiteurAnonymeVoitLaDiscussionPartagee(): void
    {
        $session = $this->createSession(partagee: true);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());

        self::assertSame(200, $this->client->getResponse()->getStatusCode());
        self::assertStringContainsString('Discussion partagée', (string) $this->client->getResponse()->getContent());
    }

    public function testUnJetonInconnuRenvoie404(): void
    {
        $this->client->request('GET', '/p/serenia/' . str_repeat('z', 32));

        self::assertSame(404, $this->client->getResponse()->getStatusCode());
    }

    public function testUnLienRevoqueRenvoie404(): void
    {
        $session = $this->createSession(partagee: true);
        $token   = (string) $session->getShareToken();

        $session->setShareToken(null)->setSharedAt(null);
        $this->em()->flush();

        $this->client->request('GET', '/p/serenia/' . $token);

        self::assertSame(404, $this->client->getResponse()->getStatusCode());
    }

    public function testLeContenuExtraitDesFichiersNEstPasServi(): void
    {
        $session = $this->createSession(partagee: true);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());

        self::assertStringNotContainsString(
            'SECRET-CHIFFRE-AFFAIRES',
            (string) $this->client->getResponse()->getContent()
        );
    }

    public function testLaPageNExposeNiUuidNiDonneeDuProprietaire(): void
    {
        $session = $this->createSession(partagee: true);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());
        $html = (string) $this->client->getResponse()->getContent();

        self::assertSame(200, $this->client->getResponse()->getStatusCode());
        self::assertStringNotContainsString($session->getUuid(), $html);
        self::assertStringNotContainsString($session->getUser()->getEmail(), $html);
        self::assertStringNotContainsString('/serenia/', $html);
    }

    public function testLesEnTetesDeSecuriteSontPresents(): void
    {
        $session = $this->createSession(partagee: true);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());
        $headers = $this->client->getResponse()->headers;

        self::assertSame('noindex, nofollow', $headers->get('X-Robots-Tag'));
        self::assertSame('no-referrer', $headers->get('Referrer-Policy'));
        self::assertStringContainsString('no-store', (string) $headers->get('Cache-Control'));
        self::assertStringContainsString("frame-ancestors 'none'", (string) $headers->get('Content-Security-Policy'));
    }

    public function testAucunCookieDeSessionNEstPose(): void
    {
        $session = $this->createSession(partagee: true);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());

        self::assertCount(0, $this->client->getResponse()->headers->getCookies());
    }

    // ============================ Aides ============================

    private function em(): EntityManagerInterface
    {
        return static::getContainer()->get(EntityManagerInterface::class);
    }

    private function createSession(bool $partagee): SereniaChatSession
    {
        $user = (new User())->setEmail('serenia-public-' . bin2hex(random_bytes(4)) . '@test.io')->setRoles(['ROLE_USER']);
        $user->setPassword(
            static::getContainer()->get(UserPasswordHasherInterface::class)->hashPassword($user, 'motdepasse')
        );
        $this->em()->persist($user);

        $session = (new SereniaChatSession($user))->setTitle('Discussion partagée');
        $session->addMessage(
            (new SereniaChatMessage())
                ->setRole('user')
                ->setContent('Peux-tu analyser ce document ?')
                ->setExtractedFileContent('SECRET-CHIFFRE-AFFAIRES 4,2 M EUR')
        );
        $session->addMessage((new SereniaChatMessage())->setRole('assistant')->setContent('Bien sûr.'));

        if ($partagee) {
            $session->setShareToken(bin2hex(random_bytes(16)))->setSharedAt(new \DateTimeImmutable());
        }

        $this->em()->persist($session);
        $this->em()->flush();

        return $session;
    }
}
```

> Adapte les aides aux signatures réelles des entités (constructeurs, accesseurs fluides ou non, cascade de persistance des messages). Reprends au besoin le motif de `tests/Functional/Controller/Scormia/ScormiaPublicShareTest.php`, relu plusieurs fois.

- [ ] **Étape 4 : lancer le test et vérifier qu'il échoue**

```bash
npm run build
php bin/phpunit -d memory_limit=512M tests/Functional/Controller/Serenia/SereniaPublicShareTest.php --testdox
```

Attendu : ÉCHEC — route inexistante, redirection vers `/login` ou 404 de routage.

- [ ] **Étape 5 : écrire le contrôleur**

`src/Controller/Serenia/SereniaPublicController.php` :

```php
<?php

declare(strict_types=1);

namespace App\Controller\Serenia;

use App\Entity\Serenia\SereniaChatSession;
use App\Repository\Serenia\SereniaChatSessionRepository;
use App\Service\Serenia\PublicSessionProjector;
use App\Service\Shared\Sharing\PublicShareGuard;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Consultation publique d'une discussion Serenia via un lien de partage.
 *
 * Aucune session n'est requise et AUCUNE écriture n'est effectuée : pas de
 * débit de tokens, pas de badge, pas de journal d'activité — ces chemins
 * présupposent un utilisateur authentifié.
 *
 * Le nom de la route DOIT commencer par `app_public_` : c'est ce préfixe qui
 * déclenche les en-têtes de sécurité de PublicShareResponseListener.
 */
#[Route('/p/serenia')]
final class SereniaPublicController extends AbstractController
{
    public function __construct(
        private readonly SereniaChatSessionRepository $sessionRepository,
        private readonly PublicSessionProjector       $projector,
        private readonly PublicShareGuard             $guard,
    ) {}

    #[Route('/{token}', name: 'app_public_serenia_show', requirements: ['token' => '[A-Za-z0-9_-]{32}'], methods: ['GET'])]
    public function show(string $token, Request $request): Response
    {
        $session = $this->resolveOrNotFound($token, $request);

        return $this->render('seren_ia/public_show.html.twig', [
            'view' => $this->projector->project($session),
        ]);
    }

    /**
     * Résout un jeton ou lève un 404 STRICTEMENT identique dans tous les cas
     * d'échec — jeton inexistant, révoqué, discussion supprimée. Aucune réponse
     * ne doit permettre de déduire ce qui existe.
     */
    private function resolveOrNotFound(string $token, Request $request): SereniaChatSession
    {
        $this->guard->assertNotFlooding($request);

        $session = $this->sessionRepository->findOneBySharedToken($token);
        if (!$session) {
            $this->guard->registerFailure($request);

            throw $this->createNotFoundException();
        }

        return $session;
    }
}
```

- [ ] **Étape 6 : écrire la page publique**

`templates/seren_ia/public_show.html.twig` :

```twig
{#
  templates/seren_ia/public_show.html.twig
  Page publique d'une discussion Serenia partagée par lien. Lecture seule.

  N'étend PAS base.html.twig : aucune barre latérale, aucun solde de tokens,
  aucune donnée du propriétaire, aucun champ de saisie, aucune liste de
  sessions. L'uuid de la discussion est volontairement absent : c'est la clé
  des URLs authentifiées, et elle n'est pas révocable.
#}
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex, nofollow">
    <title>{{ view.title }}</title>
    {{ encore_entry_link_tags('serenia_public') }}
</head>
<body class="bg-gray-50">
    <main class="max-w-3xl mx-auto px-4 py-10"
          data-controller="seren-ia--public-show">
        <h1 class="text-2xl font-bold text-gray-900 mb-8">{{ view.title }}</h1>

        {#
          Bloc de données : type application/json, donc non exécutable et hors
          du champ de script-src. Les drapeaux HEX ferment la sortie du bloc par
          un contenu utilisateur comportant « </script> ».
        #}
        <script type="application/json" data-seren-ia--public-show-target="messages">
            {{- view.messages|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_HEX_TAG') b-or constant('JSON_HEX_AMP') b-or constant('JSON_HEX_APOS') b-or constant('JSON_HEX_QUOT'))|raw -}}
        </script>

        <div class="flex flex-col gap-6" data-seren-ia--public-show-target="thread"></div>
    </main>
    {{ encore_entry_script_tags('serenia_public') }}
</body>
</html>
```

- [ ] **Étape 7 : écrire le rendu côté client**

`assets/controllers/seren_ia/public_show_entry.js` :

```js
import '../../styles/app.css'
import { Application } from '@hotwired/stimulus'
import PublicShowController from './public_show_controller.js'

const application = Application.start()
application.register('seren-ia--public-show', PublicShowController)
```

`assets/controllers/seren_ia/public_show_controller.js` :

```js
import { Controller } from '@hotwired/stimulus'
import MarkdownRenderer from '../utils/seren_ia/markdown_renderer.js'

/**
 * Rendu en lecture seule d'une discussion partagée publiquement.
 *
 * Le contenu des messages est du Markdown produit par le modèle. MarkdownRenderer
 * est configuré avec `html: false` : le HTML brut présent dans la source est
 * ÉCHAPPÉ, pas interprété. C'est ce réglage qui rend l'injection dans innerHTML
 * sûre ici — ne le relâche pas, et n'introduis pas de second chemin de rendu.
 */
export default class extends Controller {
    static targets = ['messages', 'thread']

    connect() {
        const messages = JSON.parse(this.messagesTarget.textContent)

        for (const message of messages) {
            this.threadTarget.appendChild(this.#buildBubble(message))
        }
    }

    #buildBubble(message) {
        const isUser = message.role === 'user'

        const wrapper = document.createElement('div')
        wrapper.className = isUser ? 'flex justify-end' : 'flex justify-start'

        const bubble = document.createElement('div')
        bubble.className = isUser
            ? 'max-w-[85%] rounded-2xl px-5 py-3 bg-primary text-white'
            : 'max-w-[85%] rounded-2xl px-5 py-3 bg-white border border-gray-100 shadow-sm text-gray-900'
        bubble.innerHTML = MarkdownRenderer.toHtml(message.content)

        wrapper.appendChild(bubble)

        return wrapper
    }
}
```

> Vérifie la forme d'export réelle de `markdown_renderer.js` (export par défaut ou nommé) et adapte l'import. Vérifie aussi le nom de l'entrée de styles utilisée par les autres pages autonomes.

- [ ] **Étape 8 : lancer les tests et vérifier qu'ils passent**

```bash
npm run build
php bin/phpunit -d memory_limit=512M tests/Functional/Controller/Serenia/ tests/Functional/Controller/ProtectedRoutesTest.php --testdox
```

Attendu : SUCCÈS.

- [ ] **Étape 9 : ajouter le test de non-régression XSS et l'oracle d'écriture**

Ajoute à `SereniaPublicShareTest` :

```php
    public function testUnMessagePiegeNEstPasInterprete(): void
    {
        $session = $this->createSession(partagee: true);
        $session->addMessage(
            (new SereniaChatMessage())->setRole('assistant')->setContent('<img src=x onerror=alert(1)>')
        );
        $this->em()->flush();

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());
        $html = (string) $this->client->getResponse()->getContent();

        // Le bloc de données est encodé avec JSON_HEX_TAG : les chevrons sortent
        // en <, et markdown-it (html: false) échappera le reste au rendu.
        self::assertStringNotContainsString('<img src=x onerror=', $html);
    }

    public function testAucuneEcritureEnBaseDepuisUneRequeteAnonyme(): void
    {
        $session   = $this->createSession(partagee: true);
        $fichier   = static::getContainer()->getParameter('kernel.project_dir') . '/var/data_test.db';
        $avant     = sha1_file($fichier);

        $this->client->request('GET', '/p/serenia/' . $session->getShareToken());

        self::assertSame(200, $this->client->getResponse()->getStatusCode());
        self::assertSame($avant, sha1_file($fichier), 'La consultation publique a écrit en base.');
    }
```

> L'oracle par empreinte du fichier SQLite est le motif déjà employé sur les routes publiques existantes. Vérifie le chemin réel de la base de test avant d'écrire.

- [ ] **Étape 10 : commit**

```bash
git add src/Controller/Serenia/ templates/seren_ia/public_show.html.twig assets/controllers/seren_ia/ config/packages/security.yaml webpack.config.js tests/Functional/
git commit -m "feat(serenia): page publique d'une discussion partagée par lien"
```

---

## Tâche 5 : Endpoint propriétaire et modale sans téléchargement

**Fichiers :**
- Modifier : `src/Controller/Serenia/SereniaController.php`, `templates/_molecules/share-link/share-link-modal.html.twig`
- Test : `tests/Functional/Controller/Serenia/SereniaShareEndpointTest.php`

**Interfaces :**
- Consomme : `App\Service\Shared\Sharing\ShareLinkService::enable(ShareableInterface): string` et `::revoke(ShareableInterface): void`.
- Produit : route `app_seren_ia_share_update` (`POST /serenia/session/{uuid}/partage`), réponse `{ enabled: bool, url: string|null }`.

- [ ] **Étape 1 : rendre la case téléchargement optionnelle dans la molécule**

Dans `templates/_molecules/share-link/share-link-modal.html.twig`, entoure le bloc de l'interrupteur « Autoriser le téléchargement » :

```twig
{% if withDownload|default(true) %}
    {# … interrupteur de téléchargement existant … #}
{% endif %}
```

Documente le paramètre dans l'en-tête de la molécule, à côté des autres. **Les appels existants de Slidia et Scormia ne passent pas ce paramètre et doivent continuer de rendre la case** — d'où le défaut à `true`. Vérifie-le en relançant leurs tests d'interface.

- [ ] **Étape 2 : écrire le test qui échoue**

`tests/Functional/Controller/Serenia/SereniaShareEndpointTest.php` — calque sa structure sur `tests/Functional/Controller/Scormia/ScormiaShareEndpointTest.php`, qui a été relu plusieurs fois et sait construire un utilisateur actif et lire le jeton CSRF rendu dans le DOM. Les cas à couvrir :

- le propriétaire active le partage et reçoit une URL contenant `/p/serenia/` ;
- la révocation renvoie `url: null`, et un `GET` sur l'ancienne URL publique renvoie 404 ;
- un jeton CSRF **invalide** et un jeton **absent** sont tous deux refusés en 403, **avant** toute modification d'état (relis la session après coup et vérifie qu'elle n'est pas partagée) ;
- un autre utilisateur connecté reçoit un **404 JSON** et ne peut ni partager, ni révoquer le lien vivant d'autrui ;
- un anonyme est refusé ;
- réactiver un partage déjà actif **conserve le même jeton** — sans quoi un lien déjà diffusé serait cassé.

- [ ] **Étape 3 : lancer le test et vérifier qu'il échoue**

Lancer : `php bin/phpunit -d memory_limit=512M tests/Functional/Controller/Serenia/SereniaShareEndpointTest.php --testdox`
Attendu : ÉCHEC — route inexistante.

- [ ] **Étape 4 : écrire l'endpoint**

Dans `src/Controller/Serenia/SereniaController.php` :

```php
    /**
     * Active ou révoque le lien de partage public d'une discussion.
     *
     * Le contrôle de propriété renvoie un 404 JSON — jamais un 403 : révéler
     * qu'une discussion existe mais appartient à quelqu'un d'autre est déjà une
     * information de trop.
     */
    #[Route('/session/{uuid}/partage', name: 'app_seren_ia_share_update', methods: ['POST'])]
    public function updateShare(string $uuid, Request $request, ShareLinkService $shareLinks): JsonResponse
    {
        $session = $this->sessionRepository->findOneBy(['uuid' => $uuid]);
        if ($session === null || $session->getUser()->getId() !== $this->getUser()->getId()) {
            return $this->json(['message' => 'Discussion introuvable'], 404);
        }

        $payload = json_decode((string) $request->getContent(), true) ?: [];

        if (!$this->isCsrfTokenValid('share_link', (string) ($payload['_token'] ?? ''))) {
            return $this->json(['message' => 'Votre session a expiré, rechargez la page'], 403);
        }

        if (!(bool) ($payload['enabled'] ?? false)) {
            $shareLinks->revoke($session);

            return $this->json(['enabled' => false, 'downloadEnabled' => false, 'url' => null]);
        }

        $token = $shareLinks->enable($session);

        return $this->json([
            'enabled'         => true,
            'downloadEnabled' => false,
            'url'             => $this->generateUrl(
                'app_public_serenia_show',
                ['token' => $token],
                UrlGeneratorInterface::ABSOLUTE_URL
            ),
        ]);
    }
```

Ajoute les `use` : `App\Service\Shared\Sharing\ShareLinkService`, `Symfony\Component\HttpFoundation\JsonResponse`, `Symfony\Component\Routing\Generator\UrlGeneratorInterface`.

> `downloadEnabled` est renvoyé à `false` en dur : le contrôleur Stimulus de la modale lit cette clé, la lui retirer demanderait de toucher un fichier corrigé sur trois rondes de revue. La case est masquée côté template, la valeur est inerte.

- [ ] **Étape 5 : lancer les tests et vérifier qu'ils passent**

```bash
npm run build
php bin/phpunit -d memory_limit=512M tests/Functional/Controller/ --testdox
```

Attendu : SUCCÈS, y compris les tests d'interface de Slidia et Scormia — la case téléchargement doit toujours s'y afficher.

- [ ] **Étape 6 : commit**

```bash
git add src/Controller/Serenia/SereniaController.php templates/_molecules/share-link/ tests/Functional/Controller/Serenia/
git commit -m "feat(serenia): endpoint propriétaire de partage et modale sans téléchargement"
```

---

## Tâche 6 : Bandeau, entrée de menu et pastille

**Fichiers :**
- Modifier : `templates/seren_ia/index.html.twig`, `templates/seren_ia/_sessions-list.html.twig`
- Test : `tests/Functional/Controller/Serenia/SereniaShareUiTest.php`

**Le bandeau est la contrepartie du mode « vivant ».** La discussion partagée reste à jour : tout ce qui est écrit après le partage devient public. Sans rappel permanent, l'utilisateur l'oublie et continue d'écrire. **Livrer le mode vivant sans le bandeau serait un défaut, pas une livraison partielle.**

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

`tests/Functional/Controller/Serenia/SereniaShareUiTest.php` : un utilisateur connecté ouvre `/serenia/s/{uuid}`.

- session **partagée** → le HTML contient le bandeau et l'URL publique `/p/serenia/` ;
- session **non partagée** → ni bandeau, ni URL publique ;
- la liste des sessions rend l'entrée « Partager » et les attributs `data-shared` / `data-share-url` sur l'élément de session ;
- la modale est présente une seule fois et rend `csrf_token('share_link')`.

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

Lancer : `php bin/phpunit -d memory_limit=512M tests/Functional/Controller/Serenia/SereniaShareUiTest.php --testdox`

- [ ] **Étape 3 : ajouter le bandeau**

Dans `templates/seren_ia/index.html.twig`, au-dessus du fil de conversation :

```twig
{# Contrepartie du mode « vivant » : la discussion partagée reste à jour, donc
   tout message écrit après le partage devient public. Ce rappel doit rester
   visible PENDANT la conversation, pas seulement au chargement — c'est sa
   seule raison d'être. #}
<div data-serenia-share-banner
     class="flex items-center justify-between gap-4 px-4 py-3 mb-4 rounded-xl bg-amber-50 border border-amber-200"
     style="{{ session.isShared ? '' : 'display:none' }}">
    <div class="flex items-center gap-2 min-w-0">
        {% include '_atoms/icon/icon.html.twig' with {name: 'share', size: 'sm', color: 'current'} only %}
        <span class="text-sm text-amber-900">
            Cette discussion est <strong>publique</strong> : tout nouveau message y sera visible.
        </span>
    </div>
    <div class="flex items-center gap-2 shrink-0">
        <input type="text" readonly data-serenia-share-url
               class="text-xs bg-white border border-amber-200 rounded-lg px-2 py-1 w-56"
               value="{{ session.isShared ? url('app_public_serenia_show', {token: session.shareToken}) : '' }}">
        <button type="button" data-action="click->seren-ia--chat#openShareModal"
                class="text-sm font-medium text-amber-900 underline cursor-pointer">Gérer</button>
    </div>
</div>
```

Adapte les noms de cible et d'action au contrôleur Stimulus réel de la page — lis `assets/controllers/seren_ia/chat_controller.js` avant d'écrire, et relaie vers le contrôleur `shared--share-link` comme le font `slidia--list#openShareModal` et `scormia--library#openShareModal`.

- [ ] **Étape 4 : ajouter l'entrée de menu et la pastille**

Dans `templates/seren_ia/_sessions-list.html.twig`, ajoute au menu de chaque session une entrée « Partager » relayant vers la modale, et une pastille « Partagé » aux métriques de ses voisines. Pose sur l'élément de session les attributs `data-uuid`, `data-shared` et `data-share-url`, que `share_link_controller.js` lit pour basculer l'affichage sans rechargement.

**Ne pose pas seulement `data-shared`** : le contrôleur écrit les trois attributs, n'en poser qu'un laisse le dataset à moitié initialisé — défaut déjà rencontré sur la carte Scormia, où il rendait la resynchronisation inopérante à la première révocation.

- [ ] **Étape 5 : inclure la modale**

Dans `templates/seren_ia/index.html.twig`, une seule fois :

```twig
{% include '_molecules/share-link/share-link-modal.html.twig' with {
    endpoint: path('app_seren_ia_share_update', {uuid: '__UUID__'})|replace({'__UUID__': '{uuid}'}),
    accentHex: tool_color_details['seren_ia'].primary,
    withDownload: false
} only %}
```

Vérifie la clé réelle de l'outil dans `tool_color_details` avant d'écrire.

- [ ] **Étape 6 : lancer les tests et vérifier qu'ils passent**

```bash
npm run build
php bin/phpunit -d memory_limit=512M tests/Functional/ --testdox
```

- [ ] **Étape 7 : commit**

```bash
git add templates/seren_ia/ tests/Functional/Controller/Serenia/SereniaShareUiTest.php
git commit -m "feat(serenia): bandeau de discussion publique, entrée de menu et pastille"
```

---

## Tâche 7 : Vérification finale

Tu ne modifies rien : tu constates et tu rapportes. Si tu trouves un défaut, signale-le.

- [ ] **Étape 1 : suite complète**

```bash
npm run build
php bin/phpunit -d memory_limit=512M --testdox
```

Consigne le nombre exact de tests et d'assertions. Distingue toute dépréciation nouvelle de la préexistante (`CsvFormulaGuard`, PHP 8.4).

- [ ] **Étape 2 : revue de la surface publique**

```bash
php bin/console debug:router | grep -i "app_public"
php bin/console debug:router --show-controllers | grep " /p/"
```

Confirme que seules les routes attendues existent, en `GET` uniquement, et qu'aucune autre route n'est devenue publique. Relis la règle `access_control` ajoutée : slash final présent, placée avant `^/`.

- [ ] **Étape 3 : chasse au 500 anonyme**

Sur la nouvelle route, en environnement **prod** (`APP_ENV=prod APP_DEBUG=0`) — en dev, la page d'exception masque la classe de défaut recherchée. Jetons malformés, encodages, octet nul, longueurs limites, méthodes HTTP non autorisées, en-têtes `Accept` exotiques. Tout doit sortir en 404, 405 ou 429 — **jamais en 500**.

- [ ] **Étape 4 : vérification manuelle sans session**

Active un partage depuis un compte, puis ouvre le lien sans cookie (`curl` sans bocal à cookies suffit). Vérifie : pas de redirection vers `/login`, pas de barre latérale, pas de solde de tokens, pas de nom de propriétaire, et **aucune trace du contenu extrait des fichiers**. Révoque, recharge : 404.

- [ ] **Étape 5 : point de synthèse**

Rends compte de : tout test volontairement non écrit et pourquoi ; tout écart entre le plan et le code livré ; et l'état du bandeau — reste-t-il visible pendant la conversation, ou seulement au chargement ?
