# Notifications Actions — Plan V4 (passe 2, révision consolidée)

Statut: prêt I1 (GO après docs/tests)
Responsables: Front (UI parent + orchestrateur), Back (NonBoardBus, lecture notifs, schéma)
Périmètre: actions interactives dans les notifications + socle d’actions minimal réutilisable (sans moteur générique)

## 0) Lignes rouges & Contexte
- V4: UI rend, moteur décide; mutations via `POST /api/commands`; réponses JSON canoniques.
- Notifications rendues en iframe sandboxée, **sans réseau**, CSP stricte; aucune logique métier/HTTP dans l’iframe.
- Objectif: exposer des actions inside (boutons) de façon sûre; standardiser un mapping vers des commandes NonBoard existantes.

## 1) Objectifs I1
- Actions inside déclaratives (shortcodes → boutons) transmises au parent via `postMessage` sécurisé `(event.source, nonce)`.
- Manifeste d’actions par notification, **persisté côté serveur** (autorité), renvoyé en lecture.
- Exécution low‑risk: commandes NonBoard existantes (`User.SubscribeCategory`, `User.UnsubscribeCategory`, `User.SetCategoryFrequencyOverride`).
- Dialogues si besoin: parent ouvre l’orchestrateur (confirm/form) avant la commande.
- **Sécurité renforcée**: pas de JS auteur, CSP `script-src 'nonce-…'`, sanitize HTML.

## 2) Catalogue V1 (références) + Snapshot‑light par notification (I1)
- Tables (MySQL):
  - `action_catalog` → `action_id VARCHAR(128) PK`, `kind VARCHAR(128) NOT NULL`, `created_at BIGINT`, `created_by INT NULL`.
  - `action_catalog_versions` → PK `(action_id, version)`, `status ENUM('draft','active','deprecated','disabled')`, `definition_json JSON NOT NULL`, `etag VARCHAR(80) NOT NULL`, `updated_at BIGINT`, `updated_by INT NULL`. Index recommandés: `(status)`, `(action_id, status)`; optionnel `UNIQUE(action_id, version, etag)`.
- Définition par version (`definition_json`): `{ label{i18n}, payloadSchema, defaultParams, capabilities[], requiresActionToken(false en I1), followUpType?, auditTag }`. `etag = sha256(canonical(definition_json))`.
- Notifications (`notifications_rich`):
  - `actions_ref_json JSON NOT NULL DEFAULT '{"actions":[]}'` — références compactes: `{ ref: action_id, version, params }`.
  - `actions_snapshot_json JSON NOT NULL DEFAULT '{"version":1,"policy":"snapshot","actions":[]}'` — snapshot‑light au moment de l’édition: pour chaque action, stocker `ref`, `version`, `label` (résolu), `params` (effectifs), `versionEtag` (de la version), éventuellement un `id` local, et `policy` (par défaut `snapshot`).
- Résolution/lecture: un service `ActionResolver` charge `(action_id, version)` (cache mémoire), vérifie `status='active'`, merge `defaultParams + params`, et renvoie un manifeste résolu. Si un `snapshot` est fourni, vérifier `versionEtag`; en cas de mismatch:
  - si `policy == 'snapshot'` et la version catalogue n’est pas `disabled` → privilégier le snapshot;
  - si la version est `disabled` → refuser proprement `ACTION_DISABLED(403)` (kill‑switch global effectif).
- Admin: CRUD du catalogue (actions/versions), changement de `status`. Les notifications référencent `{ref,version,params}`; l’éditeur matérialise le snapshot‑light.

## 3) Contrat I/O (synthèse)
- Références (dans la notification, stockage):
```
{
  "actions_ref_json": {
    "actions": [
      { "ref": "subscribe_category", "version": 3, "params": { "categoryId": 42 } },
      { "ref": "unsubscribe_category", "version": 3, "params": { "categoryId": 42 } },
      { "ref": "open_prefs", "version": 1, "params": { "schemaRef": "notif-poll-v1" } }
    ]
  },
  "actions_snapshot_json": {
    "version": 1,
    "actions": [
      { "id": "sub", "ref": "subscribe_category", "version": 3, "label": {"fr":"S’abonner"}, "params": {"categoryId": 42}, "versionEtag": "sha256:..." }
    ]
  }
}
```
- Manifeste résolu (lecture API → parent):
```
{
  "manifestVersion": 3,
  "etag": "sha256:...",        // combiné (versions + params, canonicalisé)
  "actions": [
    { "id": "sub", "kind": "User.SubscribeCategory", "label": {"fr":"S’abonner"}, "params": { "categoryId": 42 }, "auditTag": "notif_subscribe" },
    { "id": "pref", "kind": "OpenDialog", "label": {"fr":"Préférences"}, "schemaRef": "notif-poll-v1", "auditTag": "notif_pref" }
  ]
}
```
- Shortcode auteur: `[sb:action id="sub"]S’abonner[/sb:action]` → expansion → `<button data-sb-action-id="sub" aria-label="S’abonner">S’abonner</button>`
- Message iframe→parent: `{ kind:"sb:action", nonce:"…", notificationId:123, actionId:"sub" }`
- Exécution: `User.*` → `POST /api/commands`; `OpenDialog` → orchestrateur → `POST /api/commands`.
- Réponses: succès `{ ok:true }`; erreurs `{ ok:false, code, message }` (codes stables: voir §6).

## 4) Sécurité (I1)
- Iframe: `sandbox="allow-scripts"` (sans same-origin/forms), CSP stricte: `default-src 'none'; connect-src 'none'; script-src 'nonce-<rand>'; style-src 'unsafe-inline'; frame-ancestors 'none'`.
- Interdiction de JS auteur: **supprimer `content_js`** du rendu; seul un **préambule maison** (avec `nonce`) est injecté pour gérer shortcodes/clics/postMessage.
- Sanitize HTML: strip `<script>`/attributs `on*`, whitelist minimale de tags/attrs; préserver shortcodes.
- postMessage: registre des `contentWindow` + `nonce` par frame (handshake au mount); ignorer tout message dont `(source, nonce)` ne match pas. `origin` est `null` en `srcdoc`, ne pas l’utiliser côté parent.
- Autorité serveur: `actionId` n’est pas un secret; mapping réalisé côté serveur via le manifeste persistant; `auditTag` dérivé côté serveur uniquement.

## 5) Orchestration côté parent (I1)
```
const frames = new Set();            // contentWindow connus
const nonces = new Map();            // frame -> nonce
const manifests = new Map();         // notificationId -> { actions, manifestVersion, etag }

window.addEventListener('message', (e) => {
  if (!frames.has(e.source)) return;
  const m = parseSafe(e.data);
  if (!m || m.kind !== 'sb:action') return;
  if (m.nonce !== nonces.get(e.source)) return;
  dispatchAction(m.notificationId, m.actionId);
});

async function dispatchAction(notificationId, actionId) {
  const mf = manifests.get(notificationId);
  if (!mf) return toastError('ACTION_UNKNOWN');
  const def = mf.actions.find(a => a.id === actionId);
  if (!def) return toastError('ACTION_UNKNOWN');
  // UX/a11y: marquer le bouton comme occupé (aria-busy) et disabled le temps de l’exécution
  if (def.kind === 'OpenDialog') {
    const values = await openDialog(def.schema || def.schemaRef);
    if (!values) return; // cancelled
    return postCommand(def.followUpType, { ...def.params, ...values });
  }
  return postCommand(def.kind, def.params);
}
```
Notes UX: lors du clic, appliquer `disabled` et `aria-busy="true"` au bouton; réactiver à la réponse. Traiter `409 CONFLICT_ALREADY_SET` comme un succès idempotent (toast).

## 6) Erreurs & Permissions (I1)
- Codes stables: `UNAUTHORIZED(401)`, `FORBIDDEN(403)`, `ACTION_NOT_ALLOWED(403)`, `ACTION_DISABLED(403)`, `PAYLOAD_INVALID(422)`, `CONFLICT_ALREADY_SET(409)`, `RATE_LIMITED(429)`, `INTERNAL_SERVER_ERROR(5xx)`.
- Idempotence: handlers User.* par upsert; UI désactive les boutons pendant l’appel et gère `409` comme succès idempotent.
- Permissions & acteur: re‑vérifier dans les handlers (capabilities typées, e.g. `subscription:write:self`), et prendre en compte l’acteur (`user`/`admin`/`system`). Le manifeste versionné déclare `capabilities[]`; les handlers restent l’autorité.
- Observabilité: `CommandController` audit log + corrélation (`correlationId` propagé via header → audit). Rejets postMessage loggés côté parent.

Canonicalisation ETag manifeste résolu: concaténer les `versionEtag` triés et un JSON canonique des `params` (clés triées) puis `sha256`; évite les faux diffs d’ordre des clés.

## 7) Déduplication & Rate‑limit (I1)
- Dédup actions non idempotentes (préparation I2+): table `action_dedup` avec contrainte UNIQUE `(user_id, action_id, version, params_hash, window_bucket)`; MySQL: `INSERT IGNORE` ou `ON DUPLICATE KEY UPDATE created_at=created_at`.
- Fenêtrage TTL par buckets (1–5 min) + purge `created_at < now()-window`. Index sur `created_at`.
- Rate‑limit fin par `(userId, actionId)`: table dédiée calquée sur `ip_rec` existante; seuils ex. 5/min, burst 3; erreurs `RATE_LIMITED(429)` si applicable.

## 8) Admin — Palette actions (I1)
- Parcourir le **catalogue** (versions `active`) et choisir `{action_id, version}`; saisir les `params` (validés contre `payloadSchema`).
- Persister les références dans `actions_ref_json`; matérialiser un **snapshot‑light** (`actions_snapshot_json`) avec labels finalisés, params effectifs, `versionEtag` et `policy` (défaut `snapshot`).
- Générer les shortcodes (`[sb:action id="..."]`) + prévisualisation.
 - Validation Admin: `definition_json.payloadSchema` suit un JSON Schema strict (ex. draft‑07) avec `additionalProperties:false`. Compiler/cache côté serveur. Whitelister `kind` et `capabilities`.

## 9) Itérations
- I1 — Catalogue‑first + Pont actions safe‑by‑default
  - Schéma: `action_catalog`, `action_catalog_versions`, `actions_ref_json` (+ `actions_snapshot_json` light) ; validations Admin.
  - Service `ActionResolver` (cache mémoire) + lecture enrichie (`actions` résolues voire vérifiées vs snapshot‑light/etag). Cache clé: `action:{action_id}:v{version}` → `{definition_json, etag, status}`; invalidation au bump de version/etag; TTL long.
  - Iframe: sanitize + expansion `[sb:action]` → boutons; **pas de `content_js`**; préambule nonce; CSP stricte.
  - Parent: registre frames+nonces; routeur message; `dispatchAction()` → `/api/commands`; orchestrateur `OpenDialog`.
  - Handlers: idempotence confirmée; codes d’erreur canoniques; audit + correlationId.
- I2 — `actionToken` JIT + capabilities renforcées
  - `ActionTokenService` HMAC(uid, notifId, action_id, version, paramsHash, exp, etagVersion) avec secret `.env`.
  - Endpoint lecture JIT: `GET /api/notifications/:id/actions/:actionId/token` (TTL court), vérification côté back.
  - Actions sensibles (ban, trophées, claim/validate) exigent token + capabilities.
- I3 — Catalogue & métriques
  - Nouveaux `kind`: `UserData.SetKey` (self), ban admin, trophées/récompenses; télémétrie agrégée (usage par actionId, outcome).

## 10) Tests & DoD I1
- Sanitize HTML (fuzz) + expansion shortcodes → aucun `<script>`/`on*` restant.
- Iframe CSP/sandbox vérifiée (`connect-src 'none'`, `script-src 'nonce-…'`).
- postMessage: rejets source inconnue/nonce invalide; handshake/cleanup OK.
- Exécution Subscribe/Unsubscribe/Override via `/api/commands`: 200 OK + erreurs canoniques (401/403/422/409).
- UI: états pending/success/error; double‑clic n’exécute pas deux fois (idempotence).
- Docs mises à jour (README/PLAN/PROGRESS). 

## 13) Checklist GO (I1)
- Catalogue: tables `action_catalog` / `action_catalog_versions` + CRUD Admin (status `draft/active/deprecated/disabled`).
- Refs: `actions_ref_json` dans la notif (+ `actions_snapshot_json` light: `ref`, `version`, `label`, `params`, `versionEtag`, `policy="snapshot"`).
- Resolver: merge `defaultParams + params`, vérifie `status='active'`, renvoie manifeste résolu + `etag` combiné (canonicalisé); kill‑switch → `ACTION_DISABLED(403)`.
- Iframe: sanitize HTML, pas de JS auteur, préambule nonce, shortcodes → boutons, `postMessage`.
- Parent: registre `(contentWindow, nonce)`, dispatch vers `/api/commands`, intégration `OpenDialog`, états pending/success/error (+ a11y).
- Handlers: upserts idempotents, permissions/capabilities, codes d’erreurs canoniques, audit + correlationId.
- Tests: CSP/sandbox, sanitize (fuzz), postMessage (nonce/source), lecture/exécution, 409 idempotent, mismatch etag (policy), disabled → 403.

## 11) Risques & mitigations
- Spoof postMessage → filtre `(source, nonce)` + registre + logs.
- Connaissance `actionId` → sans session/CSRF + mapping serveur, aucun effet.
- HTML malicieux → sanitize + CSP + sandbox (aucun réseau depuis l’iframe).
- Clickjacking parent → `frame-ancestors 'none'` (et `X-Frame-Options: DENY` en complément si pertinent côté admin).

## 12) Points d’intégration (références)
- Front notifications: `public/assets/packages/ui/notifications-rich.js`
- Orchestrateur/dialogues: `public/assets/packages/ui/dialog.js`, `public/assets/packages/ui/modal.js`
- Back lecture: `src/Application/Services/NotificationRichReadService.php`
- Back admin: `src/Application/Services/Admin/NotificationRichAdminService.php`
- Commandes NonBoard: `User.SubscribeCategory`, `User.UnsubscribeCategory`, `User.SetCategoryFrequencyOverride`
