# Partage public d'une discussion Serenia — design

Date : 2026-08-01
Branche : `feat/partage-public-slidia-scormia` (worktree)
Précède : `2026-07-31-partage-public-slidia-scormia-design.md`, dont ce chantier réutilise l'intégralité du dispositif.

## 1. Objectif

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

C'est le premier des trois chantiers issus du cadrage « partage entre comptes ». Les deux autres — envoi d'une copie vers un autre compte (Pixia, Monalisia, Castia) et partage d'accès avec verrou d'édition (Moodia, Slidia, Scormia) — feront chacun l'objet de leur propre spec.

## 2. Décisions arrêtées

| Sujet | Décision |
|---|---|
| Contenu publié | La discussion seule : titre, puis les messages |
| Fraîcheur | **Vivant** — le visiteur voit toujours la discussion à jour |
| Garde-fou associé | **Bandeau permanent** dans la conversation authentifiée tant que le lien est actif |
| Fichiers déposés | **Rien** n'apparaît : ni nom, ni fichier, ni contenu extrait |
| Téléchargement | Aucun — sans objet pour cet outil |
| Protection | Révocation, comme les autres outils. Pas d'expiration, pas de code d'accès |

Le choix « vivant » n'est acceptable **que** parce qu'il est accompagné du bandeau : sans rappel visuel, l'utilisateur continue d'écrire dans une discussion publique sans y penser. Les deux vont ensemble ; livrer l'un sans l'autre serait un défaut, pas une livraison partielle.

## 3. Modèle de données

`App\Entity\Serenia\SereniaChatSession` reçoit `ShareableInterface` et `ShareableTrait`, déjà en place :

| Colonne | Type | Notes |
|---|---|---|
| `share_token` | `VARCHAR(32) NULL UNIQUE` | `NULL` = non partagé |
| `shared_at` | `DATETIME NULL` | |
| `share_download_enabled` | `BOOLEAN NOT NULL DEFAULT false` | Hérité du trait, **inutilisé ici** |

`share_download_enabled` vient du trait et n'a pas d'usage pour Serenia. Le laisser à `false` et ne jamais l'exposer coûte moins que de fragmenter le trait ; la modale de partage masquera simplement sa case pour cet outil.

Migration générée par `make:migration`. **La base de dev est partagée avec d'autres agents** : relire le fichier généré et supprimer toute instruction ne portant pas sur `serenia_chat_session`, dans `up()` comme dans `down()`.

## 4. Projection publique

`App\Service\Serenia\PublicSessionProjector::project(SereniaChatSession): array`, **liste blanche explicite**, jamais liste noire.

**Publié** — session : `title`. Chaque message : `role`, `content`, `createdAt`.

**Exclu, et la raison :**

| Champ | Pourquoi |
|---|---|
| `extractedFileContent` | **Le point critique.** Texte intégral des fichiers déposés dans la conversation — invisible à l'écran, envoyé 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 |
| `embedding` | Vecteur interne |
| `tokenCount`, `modelUsed` | Consommation et modèle : donnée de facturation |
| `contextSummary`, `contextSummaryUpdatedAt` | Mémoire interne de la conversation, reformulée par le modèle |
| `communicationStyle`, `workMode`, `aiModel` | Réglages du propriétaire |
| `uuid` (session et message) | Clé des URLs authentifiées, non révocable |
| `isArchived`, `isPinned` | État d'organisation privé |
| Propriétaire | Aucune donnée : ni nom, ni e-mail |

Un test unitaire fige **exhaustivement** les clés de premier niveau et les clés de message (`assertSame` sur `array_keys`), sur le modèle de `PublicPresentationProjectorTest` et `PublicModuleProjectorTest`. Sans cela, un champ ajouté plus tard à la sérialisation partirait au public sans faire rougir un test.

## 5. Route et contrôleur

```
GET /p/serenia/{token}    app_public_serenia_show
```

Contrainte de route : `{token}` limité à `[A-Za-z0-9_-]{32}`. Utiliser **`[0-9]`, jamais `\d`**, dans toute contrainte numérique de cette route : Symfony compile avec le modificateur `u`, qui fait matcher `\d` sur les chiffres Unicode — défaut déjà rencontré sur la route de vignette Slidia, où il produisait un 500 anonyme avant le contrôleur.

Règle `access_control` dans `config/packages/security.yaml`, **avant** la règle fourre-tout `^/` :

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

**Le slash final est impératif.** Le test `ProtectedRoutesTest::testAucuneRegleAccessControlPubliqueNeDebordeSurUnOutilProtege` verrouille cette précision et devra couvrir le nouveau préfixe.

Contrôleur neuf : `src/Controller/Serenia/SereniaPublicController.php`, sans `#[IsGranted]`, dans la tranche de l'outil. Il résout le jeton via `SereniaChatSessionRepository::findOneBySharedToken()` (filtrant explicitement `shareToken IS NOT NULL`), passe par `PublicShareGuard` (`assertNotFlooding`, `registerFailure`) et lève un **404 strictement identique** pour tout échec.

**Le nom de route doit commencer par `app_public_`** : c'est ce préfixe qui déclenche `PublicShareResponseListener` (CSP, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`).

**Aucune écriture en base depuis ce chemin** : pas de débit de tokens, pas de badge, pas d'`ActivityLog`. Un test d'oracle par empreinte du fichier SQLite de test le verrouille, comme sur les routes existantes.

## 6. La page publique

`templates/seren_ia/public_show.html.twig` — autonome, n'étend pas `base.html.twig`.

Contenu : le titre de la discussion, puis les messages en bulles utilisateur / assistant, dans l'ordre chronologique. Pas de champ de saisie, pas de barre latérale, pas de liste de sessions, aucun élément de navigation vers l'application.

**Rendu du contenu.** Les messages sont du Markdown produit par le modèle, rendu par `assets/controllers/utils/seren_ia/markdown_renderer.js`. Ce moteur est configuré avec **`html: false`** : le HTML brut présent dans la source est échappé, pas interprété. La page publique réutilise ce moteur **sans relâcher ce réglage** — c'est ce qui rend Serenia sûr là où Slidia ne l'était pas. Un test de non-régression vérifie qu'un message contenant `<img src=x onerror=alert(1)>` ressort échappé.

Les données transitent par un bloc `<script type="application/json">`, non exécutable, encodé avec `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT`.

## 7. Le bandeau dans la conversation authentifiée

Tant que `isShared()` est vrai, la vue de conversation affiche un bandeau permanent : la mention que la discussion est publique, le lien copiable, et un accès direct à la révocation.

Il doit rester visible pendant la conversation, pas seulement au chargement — c'est sa raison d'être. S'il disparaît au premier message envoyé, le garde-fou ne tient plus et le mode « vivant » redevient un piège.

## 8. Partage et interface

Entrée « Partager » dans le menu de chaque session, ouvrant la modale commune
`templates/_molecules/share-link/share-link-modal.html.twig`, **avec sa case
« Autoriser le téléchargement » masquée** — la molécule reçoit un paramètre
`withDownload` valant `false` pour cet outil.

Endpoint propriétaire `POST /serenia/session/{uuid}/partage` (`app_serenia_share_update`), protégé par jeton CSRF `share_link`, renvoyant `{ enabled, url }` et un **404 JSON** si la session n'appartient pas à l'utilisateur — contrat aligné sur les deux autres outils.

Pastille « Partagé » sur la session dans la liste, aux métriques de sa voisine, et attributs `data-shared` / `data-share-url` sur l'élément de liste pour que le contrôleur Stimulus la bascule sans rechargement.

## 9. Tests

**Unitaires** — `PublicSessionProjector` : clés de premier niveau et clés de message figées exhaustivement ; `extractedFileContent`, `embedding`, `tokenCount`, `modelUsed`, `contextSummary` et les réglages absents de la projection, y compris quand ils sont renseignés.

**Fonctionnels**, client anonyme :

- jeton valide → 200, le titre et les messages sont présents ;
- après révocation → 404 ; jeton inconnu → 404 ; session supprimée → 404, réponses identiques ;
- **fuite de charge utile** : une session dont un message porte un `extractedFileContent` produit une page publique où ce contenu est absent ;
- la page ne contient ni `uuid`, ni donnée du propriétaire, ni URL authentifiée ;
- en-têtes présents : CSP, `Referrer-Policy`, `Cache-Control: no-store`, `X-Robots-Tag` ;
- **non-régression XSS** : un message contenant `<img src=x onerror=alert(1)>` ressort échappé ;
- **aucune écriture en base** depuis la route publique, vérifié par empreinte du fichier de test ;
- le bandeau apparaît dans la conversation authentifiée quand la session est partagée, et disparaît après révocation.

## 10. Hors périmètre

Téléchargement ou export de la discussion ; filtre « Partagées » dans la liste des sessions (ajout court si souhaité plus tard, mais non demandé) ; affichage des fichiers déposés sous quelque forme que ce soit ; partage entre comptes ; verrou d'édition.
