# Axiome App Hub — Guide d'intégration front (doc pour IA) > **À qui s'adresse ce document** : à un assistant IA (Claude ou autre) travaillant dans un > projet front 100 % JavaScript qui consomme l'API d'Axiome App Hub. Copiez ce fichier dans > le projet front (ou collez-le dans `CLAUDE.md`). Il contient tout ce qu'il faut pour > lire/écrire des données et faire du temps réel, sans accès au code du hub. ## Ce qu'est le hub (en 3 phrases) Axiome App Hub est un backend mutualisé pour applications front sans serveur (mini-jeux, outils, démos). Chaque application dispose d'un **coffre** (storage vault) : un document JSON libre, lu et écrit via une API REST, identifié par un **token** `axm_…`. L'app peut aussi ouvrir des **sessions** (parties/lobbies) avec des **participants**, et — si le temps réel est activé — diffuser des messages éphémères entre navigateurs sur les **canaux de session** (SSE via Mercure) ; le hub relaie sans rien stocker, toute la logique métier reste côté front. ## Configuration requise côté front Deux valeurs, à mettre dans la config du projet (variables d'env, constantes…) : | Valeur | Exemple | Rôle | |--------|---------|------| | `HUB_API_URL` | `http://localhost:8090/api` (dev) ou `https://app-hub.kromozone.fr/api` (prod) | Base de tous les appels | | `HUB_TOKEN` | `axm_3f9c…` | Token du coffre, fourni par l'admin du hub | **Le token n'est pas un secret fort** : il transite dans le header `X-API-Key` de chaque requête et est visible dans le code front. La protection réelle vient des **règles d'origine** configurées côté hub (voir « Restrictions d'origine » plus bas). ## Règles universelles de l'API - Toutes les requêtes portent le header **`X-API-Key: `**. Pas de cookie, pas de session, pas d'OAuth : l'API est stateless. - Les corps de requête et de réponse sont en JSON. Pour les POST, envoyer `Content-Type: application/json`. - **CORS est géré par le hub** (preflight OPTIONS compris, verbes GET/POST/PUT/DELETE, headers `X-API-Key`, `X-Participant-Secret`, `X-Owner-Secret`, `Content-Type`). Ne jamais ajouter de proxy ou de contournement CORS côté front : si un appel échoue, c'est le token, l'origine ou le corps qui est en cause, pas CORS. - Toute erreur renvoie un corps `{"error": "message explicite en français"}` avec le code HTTP approprié (tableau complet en fin de document). - Taille maximale de tout corps envoyé (payload comme message temps réel) : **64 Ko**. --- ## 1. Stockage : lire et écrire le payload Le coffre contient **un seul document JSON** (objet ou tableau, structure totalement libre — c'est l'app qui décide de son schéma). ### Lire ``` GET {HUB_API_URL}/storages X-API-Key: {HUB_TOKEN} ``` Réponse `200` : ```json { "payload": { "scores": [ { "name": "Alice", "points": 42 } ] } } ``` `payload` vaut `[]` (tableau vide) tant que rien n'a été écrit — jamais `null`. ### Écrire ``` POST {HUB_API_URL}/storages X-API-Key: {HUB_TOKEN} Content-Type: application/json { "scores": [ { "name": "Alice", "points": 42 } ] } ``` Réponse `200` : `{ "payload": … }` (le payload tel qu'enregistré). **Points critiques à respecter :** 1. **Le POST REMPLACE tout le payload** — ce n'est PAS un merge/patch. Le pattern correct est toujours : `GET` → modifier en mémoire → `POST` du document complet. 2. Le corps doit être un **objet ou un tableau** JSON (un scalaire nu comme `42` ou `"texte"` est refusé en `422`). 3. Pas de verrou ni de version : **dernier écrivain gagne**. Si plusieurs clients écrivent en parallèle, concevoir le schéma pour minimiser les collisions (ex. relire juste avant d'écrire), ou faire transiter les événements par le temps réel et ne persister que ponctuellement. 4. Un coffre peut être en **lecture seule** (interrupteur côté hub) : le POST renvoie alors `403`. Prévoir ce cas dans l'UI. ### Exemple de client minimal ```js const HUB = { api: 'https://app-hub.kromozone.fr/api', token: 'axm_…' }; async function hubFetch(path, options = {}) { const res = await fetch(`${HUB.api}${path}`, { ...options, headers: { 'X-API-Key': HUB.token, ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers, }, }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`Hub ${res.status} : ${body.error ?? 'erreur inconnue'}`); return body; } const { payload } = await hubFetch('/storages'); // lire await hubFetch('/storages', { method: 'POST', body: JSON.stringify(newPayload) }); // écrire ``` --- ## 2. Temps réel : canaux de session Le temps réel est un **relais pur** (rien n'est stocké : seuls les abonnés connectés au moment de l'envoi reçoivent) et **structuré autour des sessions** — il n'y a plus de « rooms » libres. Chaque session expose jusqu'à 3 canaux, chacun activable par l'admin du coffre : | Canal | Pour quoi | Qui publie | Qui reçoit | |-------|-----------|------------|------------| | `public` | diffusion à toute la session (chat, état de jeu) | tout membre | tous les membres | | `admin` | privé entre l'admin et **un** participant | l'admin (vers un participant) **et** ce participant (vers l'admin) | l'admin + ce participant | | `inbox` | message direct vers un participant | tout membre (vers un destinataire) | le destinataire | Prérequis : `isRealtimeAllowed` activé sur le coffre **et** le canal visé activé, sinon `403`. ### Prérequis : appartenir à une session Le temps réel exige une **session** et une **identité de membre**. On l'obtient en créant ou en rejoignant une session : ``` POST {HUB_API_URL}/sessions → 201 X-API-Key: {HUB_TOKEN} { "ownerId": "client-abc" } ``` Conserver `session.id` (l'identifiant à partager pour rejoindre), `ownerSecret` (preuve d'admin) et `participant.secret` (identité du créateur comme participant). ``` POST {HUB_API_URL}/sessions/{sessionId}/join → 201 X-API-Key: {HUB_TOKEN} { "pseudo": "Bob", "payload": { "score": 0 } } // + "password" si la session en a un ``` Conserver `participant.id` et `participant.secret`. Réponses d'erreur : `404` session introuvable, `410` session expirée, `403` mot de passe requis/incorrect, `409` session complète (`maxParticipants` atteint), `429` trop de tentatives de mot de passe (header `Retry-After` en secondes — le compteur est repoussé par IP + session, remis à zéro sur un join réussi). > **Ces secrets ne sont renvoyés qu'à ce moment-là.** Le hub ne les stocke que sous forme > d'empreinte : les perdre équivaut à perdre l'accès à cette identité (session ou > participant) — il n'y a pas de récupération. L'authentification temps réel — et plus généralement toute action « de membre » sur une session — a donc **deux niveaux** : le token du coffre (`X-API-Key`) **et** le secret de membre en en-tête : - participant : `X-Participant-Secret: ps_…` - admin : `X-Owner-Secret: os_…` ### Gérer la session et ses participants (au-delà du temps réel) Ces endpoints ne servent pas qu'au temps réel : ils gèrent l'état de la session elle-même (deux payloads distincts — **partagé** au niveau session, **perso** par participant). | Méthode | Endpoint | Qui | Rôle | |---------|----------|-----|------| | `GET` | `/sessions/{sessionId}` | membre | État complet : payload partagé, métadonnées, trombinoscope (`id` + `pseudo` de chaque participant) | | `PUT` | `/sessions/{sessionId}` | admin (`X-Owner-Secret`) | Remplace le payload **partagé** (corps JSON brut = le payload) | | `DELETE` | `/sessions/{sessionId}` | admin | Détruit la session et tous ses participants | | `GET` | `/sessions/{sessionId}/participants` | admin, **si** `isParticipantPayloadRead` activé sur le coffre | Liste tous les participants avec leur payload perso | | `GET` | `/sessions/{sessionId}/participants/{participantId}` | soi-même, ou admin si `isParticipantPayloadRead` | Payload perso d'un participant | | `PUT` | `/sessions/{sessionId}/participants/{participantId}` | soi-même, ou admin si `isParticipantPayloadWrite` | Remplace le payload perso (corps JSON brut = le payload) | | `DELETE` | `/sessions/{sessionId}/participants/{participantId}` | soi-même (**quitte**), ou admin si `isAllowedToKickParticipant` (**expulse**) | Retire le participant ; son secret devient caduc | | `PUT` | `/sessions/{sessionId}/owner` | admin (`X-Owner-Secret`) | Transfère le rôle d'admin à un autre participant de la session | Toutes ces routes s'authentifient avec `X-Participant-Secret` **ou** `X-Owner-Secret` selon le rôle requis (voir colonne « Qui »). Les actions réservées à l'admin sur les participants (lire/écrire/expulser *un autre* que soi-même) sont chacune conditionnées à un interrupteur côté coffre — si désactivé, `403` même avec le bon `X-Owner-Secret`. `PUT` (session ou participant) et `DELETE` (session inexistante ou expirée) renvoient `410` si la session a expiré entre-temps. ### Transférer le rôle d'admin (owner) ``` PUT {HUB_API_URL}/sessions/{sessionId}/owner X-API-Key: {HUB_TOKEN} X-Owner-Secret: {ownerSecret} Content-Type: application/json { "participantId": 35, "ownerId": "bob-client-id" } ``` Réponse `200` : ```json { "session": { "id": "ses_ab12", "ownerId": "bob-client-id" }, "participant": { "id": 35, "pseudo": "Bob" }, "ownerSecret": "os_9f3c…" } ``` **Points critiques :** 1. `participantId` doit être un participant **déjà présent** dans la session (`404` sinon) ; `ownerId` (nouvelle identité libre de l'admin) est **requis**, comme à la création. 2. L'**ancien `ownerSecret` devient immédiatement caduc** — seul le nouveau, renvoyé **une seule fois** dans cette réponse, fait autorité ensuite. Il n'y a pas de récupération : si le front le perd avant de le transmettre au nouvel admin, il faut recréer un transfert. 3. Le participant désigné **garde son `participantSecret` inchangé** : il cumule son identité de participant existante et le nouveau rôle d'admin — pas besoin de rejoindre à nouveau. 4. Transmettre le nouvel `ownerSecret` au participant désigné est **à la charge du front** (ex. via le canal temps réel `admin`, ou un canal hors-bande) : le hub ne le pousse nulle part automatiquement. 5. Réservé à l'admin **actuel** (`X-Owner-Secret`) ; `403` sinon. `410` si la session est expirée entre-temps. ### S'abonner (recevoir) Demander un **ticket** : le hub ne délivre un JWT que pour **vos** canaux autorisés. ``` GET {HUB_API_URL}/sessions/{sessionId}/realtime/subscribe X-API-Key: {HUB_TOKEN} X-Participant-Secret: {participantSecret} // ou X-Owner-Secret pour l'admin ``` Réponse `200` : ```json { "hubUrl": "https://app-hub.kromozone.fr/.well-known/mercure", "topics": ["session/ses_ab12/public", "session/ses_ab12/admin/34", "session/ses_ab12/inbox/34"], "token": "eyJ…", "expiresIn": 3600, "subscribeUrl": "https://app-hub.kromozone.fr/.well-known/mercure?topic=…&topic=…&authorization=eyJ…" } ``` - un **participant** reçoit : le canal public, **son** canal admin, **sa** boîte (selon les canaux activés) ; - l'**admin** reçoit : le canal public et **tous** les canaux admin (gabarit `admin/{id}`). Ouvrir la connexion SSE avec `subscribeUrl`, **prête à l'emploi** : ```js const sub = await hubFetch(`/sessions/${sessionId}/realtime/subscribe`, { headers: { 'X-Participant-Secret': participantSecret }, // ou X-Owner-Secret }); const es = new EventSource(sub.subscribeUrl); es.onmessage = (e) => { const { channel, event, data, from } = JSON.parse(e.data); // router selon `channel` + `event` ; `from` = id du participant émetteur, ou "admin" }; ``` **Expiration du ticket** : le JWT expire au bout de `expiresIn` secondes (3600). Passé ce délai, la reconnexion d'`EventSource` échoue en boucle. Pattern robuste : sur `es.onerror` persistant (ou avant l'échéance), fermer l'`EventSource`, redemander un ticket, rouvrir avec la nouvelle `subscribeUrl`. ### Publier (envoyer) ``` POST {HUB_API_URL}/sessions/{sessionId}/realtime/publish X-API-Key: {HUB_TOKEN} X-Participant-Secret: {participantSecret} // ou X-Owner-Secret Content-Type: application/json { "channel": "public", "event": "chat.message", "data": { "text": "coucou" } } ``` - **`channel`** : `public` | `admin` | `inbox` - **`event`** : `[a-zA-Z0-9_.:-]`, 1 à 64 caractères - **`data`** : libre (tout JSON, `null` accepté) - **`target`** (id de participant) : **requis** pour `inbox`, et pour `admin` quand c'est **l'admin** qui publie. Un participant publie toujours sur son propre canal admin (`target` ignoré). Réponse `200` : `{ "published": true, "channel": "public" }`. Les abonnés reçoivent `{ channel, event, data, from }` dans `e.data` (stringifié). **Bonnes pratiques :** - **L'émetteur reçoit aussi son propre message** s'il est abonné au canal : ne pas afficher localement à l'envoi, attendre l'écho du hub (source d'ordre unique). - `from` identifie l'émetteur (`id` du participant, ou `"admin"`). - Pas de présence ni d'historique natifs : les construire côté front (events + payload de session). - Une session **inactive** au-delà du délai du coffre expire (`410` sur tous ses endpoints) ; publier (ou écrire un payload) repousse l'échéance et la garde vivante. --- ## 3. Restrictions d'origine (matchers) L'admin du hub peut restreindre un coffre à certaines origines. À chaque requête, le hub compare les headers `Origin`/`Referer` du navigateur aux règles du coffre : | Type | Exemple de règle | Matche | |------|------------------|--------| | `url` | `https://monjeu.fr` | exactement cette origine (slash final toléré) | | `wildcard` | `https://*.monjeu.fr` | tous les sous-domaines | | `regex` | `^https://monjeu\.(fr\|com)$` | expression régulière | Un coffre **sans règle accepte toutes les origines**. Si l'origine est refusée : `403 "Origine non autorisée."`. Conséquence pratique : un `403` en local (ex. `localhost:5173`) alors que ça marche en prod signifie presque toujours qu'il faut demander l'ajout de l'origine de dev aux règles du coffre — ce n'est pas un bug front. --- ## 4. Codes d'erreur (référence complète) | Code | Signification | Action côté front | |------|---------------|-------------------| | `401` | Header `X-API-Key` absent ou vide | Vérifier l'envoi du header | | `404` | Token/coffre inconnu ou désactivé, **ou** session / participant introuvable | Vérifier le token, le `sessionId` ou le `target` | | `403` | Origine refusée · écriture verrouillée (POST /storages) · temps réel ou canal désactivé · mot de passe requis/incorrect · secret de membre manquant | Lire le message `error` pour distinguer ; côté hub, pas côté code | | `409` | Session complète (join, capacité atteinte) | Proposer une autre session | | `410` | **Session expirée** (inactive au-delà du délai) | Recréer / rejoindre une autre session | | `413` | Corps > 64 Ko | Réduire le payload/message | | `422` | JSON invalide · payload scalaire · `ownerId`/`channel`/`event`/`participantId` invalide · `target` manquant | Corriger le corps de la requête | | `429` | Trop de tentatives de mot de passe sur une session (join) | Attendre le délai du header `Retry-After` (secondes) avant de réessayer | Le champ `error` de la réponse est un message en français directement affichable. --- ## 5. Récapitulatif des endpoints | Méthode | Endpoint | Rôle | Réponse | |---------|----------|------|---------| | `GET` | `/storages` | Lire le payload du coffre | `{ "payload": … }` | | `POST` | `/storages` | **Remplacer** le payload (JSON libre, ≤ 64 Ko) | `{ "payload": … }` | | `POST` | `/sessions` | Créer une session (→ admin + 1er participant) | `201 { session, participant, ownerSecret }` | | `POST` | `/sessions/{sessionId}/join` | Rejoindre une session | `201 { session, participant }` | | `GET` | `/sessions/{sessionId}` | État complet (membre) : payload partagé + trombinoscope | `{ id, payload, participants, … }` | | `PUT` | `/sessions/{sessionId}` | **Remplacer** le payload partagé (admin) | `{ id, payload }` | | `DELETE` | `/sessions/{sessionId}` | Détruire la session (admin) | `{ "deleted": true }` | | `GET` | `/sessions/{sessionId}/participants` | Lister tous les payloads participants (admin, si activé) | `{ participants, count }` | | `GET` | `/sessions/{sessionId}/participants/{id}` | Lire le payload d'un participant (soi-même, ou admin si activé) | `{ id, pseudo, payload }` | | `PUT` | `/sessions/{sessionId}/participants/{id}` | **Remplacer** le payload d'un participant (soi-même, ou admin si activé) | `{ id, pseudo, payload }` | | `DELETE` | `/sessions/{sessionId}/participants/{id}` | Quitter (soi-même) / expulser (admin, si activé) | `{ removed, participantCount }` | | `PUT` | `/sessions/{sessionId}/owner` | Transférer le rôle d'admin à un autre participant (admin) | `{ session, participant, ownerSecret }` | | `GET` | `/sessions/{sessionId}/realtime/subscribe` | Ticket SSE des canaux autorisés (en-tête secret) | `{ hubUrl, topics, token, expiresIn, subscribeUrl }` | | `POST` | `/sessions/{sessionId}/realtime/publish` | Publier `{ channel, event, data, target? }` (en-tête secret) | `{ "published": true, "channel": … }` | ## 6. Check-list pour l'IA qui intègre 1. Centraliser `HUB_API_URL` + `HUB_TOKEN` et un helper `fetch` unique (header `X-API-Key`). 2. Traiter le stockage en **document unique** : lire → modifier → réécrire en entier. 3. Temps réel : d'abord **créer/rejoindre une session** et **conserver** le `sessionId` + le secret de membre (`participantSecret`, ou `ownerSecret` pour l'admin). 4. S'abonner : ticket (en-tête secret) → `EventSource(subscribeUrl)` → router sur `channel` + `event` ; renouveler le ticket à l'expiration (3600 s) ou sur erreur persistante. 5. Publier avec le bon `channel` (et `target` pour `inbox` / `admin` côté admin) ; ne jamais afficher localement un message envoyé : attendre l'écho SSE. 6. Gérer explicitement `403` (origine/écriture/canal/secret), `409` (session pleine), `410` (session expirée → recréer) et `413` dans l'UI. 7. Ne pas toucher au CORS : il est géré par le hub. 8. Deux payloads distincts par session : le **partagé** (`GET`/`PUT /sessions/{id}`, admin pour écrire) et le **perso par participant** (`GET`/`PUT /sessions/{id}/participants/{id}`, soi-même ou admin si le coffre l'autorise) — ne pas les confondre.