spécification “Studio + module minimal” (prête à exécuter)
Partie haute @Antonio
Partie basse de la page @Faniry cf SUPABASE
Très bien. Ci-dessous une spécification “Studio + module minimal” (prête à exécuter) pour :
- Bouton “Ouvrir média” (signature lazy refresh)
- Création automatique du stub ir.attachment(type='url')
- Indexation Odoo Documents (documents.document + documents.folder)
- Record rules (héritées du Case)
- Contrat API du Media Gateway (signature / révocation / audit)
Je structure en tableaux “copiable”.
0) Hypothèses d’architecture (stables)
| Sujet | Choix recommandé |
|---|---|
| Stockage des gros médias | Externe (Supabase Storage ou S3), jamais en ir.attachment(binary) |
| Vérité durable | x_media_asset (provider + key + checksum + rétention + scope) |
| Vue navigable | ir.attachment(type='url') stub, URL signée TTL court |
| Rafraîchissement URL | Lazy refresh (au clic) via “Media Gateway” |
| Gouvernance & index | documents.folder (1 par Case) + documents.document pour indexer le stub |
| Attachement du stub | ir.attachment(res_model='x_media_asset', res_id=asset.id) (recommandé) |
1) Modèles Odoo (champs) — version “Odoo Studio compatible”
1.1 x_medical_case (rappel des ajouts utiles)
| Champ | Type | Requis | Commentaire |
|---|---|---|---|
| x_documents_folder_id | M2O → documents.folder | Oui | Folder dédié Case (auto-créé) |
| x_care_team_ids | M2M → res.partner | Oui | Base des droits “need-to-know” |
| x_owner_user_id | M2O → res.users | Oui | Responsable interne |
| x_patient_id | M2O → res.partner | Oui | Patient |
Si vous voulez une sécurité très propre : ajoutez aussi un M2M x_care_team_user_ids vers res.users (car les record rules sont plus simples avec res.users qu’avec res.partner).
1.2 x_media_asset (ajouts indispensables pour “stub + lazy refresh”)
| Champ | Type Odoo | Requis | Valeurs / Exemple | Commentaire |
|---|---|---|---|---|
| name | Char | Oui | CASE-2026-000123 – audio – 2026-01-02 16:15 | Nom “neutre” (pas PHI) |
| x_case_id | M2O → x_medical_case | Oui | Pivot | |
| x_patient_id | M2O → res.partner | Oui | Redondance utile | |
| x_asset_type | Selection | Oui | audio/video/image/pdf/other | |
| x_source | Selection | Oui | external / odoo_attachment | |
| x_external_provider | Selection | Oui si external | supabase/s3/azure/gcs/other | |
| x_external_key | Char | Oui si external | cases/2026/000123/audio/call_...wav | Clé stable (pas URL) |
| x_mime_type | Char | Non | audio/wav | |
| x_size_bytes | Integer | Non | 12345678 | |
| x_checksum_sha256 | Char | Non | ... | Audit intégrité |
| x_recorded_at | Datetime | Oui | Capture | |
| x_retention_policy | Selection | Oui | 30j/90j/1an/10ans | |
| x_access_scope | Selection | Oui | patient/team/expert/partner | Gouvernance |
| x_documents_folder_id | M2O → documents.folder | Oui | = folder du Case | Index Documents |
| x_stub_attachment_id | M2O → ir.attachment | Non | Le stub type=url | |
| x_signed_url_last_at | Datetime | Non | Dernier refresh | |
| x_signed_url_ttl_s | Integer | Oui | 900 | TTL cible (s) |
| x_status | Selection | Oui | pending/ready/archived/revoked | Reco pour robustesse |
| x_call_id | Char | Non | UUID FreeSWITCH | Corrélation |
1.3 x_expert_review_request (ajout recommandé pour partage)
| Champ | Type | Requis | Commentaire |
|---|---|---|---|
| x_share_package_id | M2O → x_share_package | Non | Industrialise partage externe |
| x_media_asset_ids | M2M → x_media_asset | Oui | Pièces lourdes partagées |
| x_consent_status | Selection | Oui | Trace consentement |
2) Automatisations — “Création Case” et “Création/MAJ média”
2.1 À la création d’un x_medical_case : créer le folder Documents
Méthode (Studio) : Automated Action sur x_medical_case (On Creation)
| Élément | Valeur |
|---|---|
| Trigger | On Creation |
| Modèle | x_medical_case |
| Condition | not record.x_documents_folder_id |
| Action | “Execute Python Code” (si module) ou Server Action (si autorisé) |
Pseudo-logic :
- Créer documents.folder nommé CASE-YYYY-NNNNN – <alias>
- Lier x_documents_folder_id
En Odoo Online, le code Python est limité. Si vous ne pouvez pas coder : faites une action manuelle + bouton “Créer dossier” sur le Case. Sinon, module minimal (recommandé).
2.2 À la création/MAJ d’un x_media_asset : créer le stub + index Documents
Déclencheur : quand x_status passe à ready (le fichier existe et est accessible).
| Élément | Valeur |
|---|---|
| Trigger | On Update |
| Modèle | x_media_asset |
| Condition | record.x_source == 'external' and record.x_status == 'ready' and not record.x_stub_attachment_id |
| Action | Créer ir.attachment stub + documents.document |
Stub ir.attachment (type url) :
| Champ ir.attachment | Valeur |
|---|---|
| name | record.name |
| type | url |
| url | https://placeholder.local/expired (ou vide) |
| res_model | x_media_asset |
| res_id | record.id |
| mimetype | record.x_mime_type (si dispo) |
Index Documents documents.document :
| Champ documents.document | Valeur |
|---|---|
| name | record.name |
| folder_id | record.x_documents_folder_id |
| attachment_id | stub_attachment.id |
| tag_ids | tags (Nature=Audio, Sensibilité=PHI_Haut, Rétention=90j, etc.) |
Si vous ne créez pas documents.document, le stub reste visible en pièces jointes du record x_media_asset, mais vous perdez la puissance de Documents (tags, vues, workflows).
3) Bouton “Ouvrir média” (Lazy refresh) — design exact
3.1 UX cible
Sur la fiche x_media_asset :
- Bouton primaire : Ouvrir média
- Badge : x_status, x_access_scope, x_retention_policy
- Alerte si x_status != ready
3.2 Fonction serveur (module minimal recommandé)
Nom : action_open_media()
Logique :
- Vérifier droits (Case/team/expert)
- Appeler Media Gateway → obtenir signed_url + expires_at
- Écrire stub_attachment.url = signed_url
- Mettre à jour x_signed_url_last_at = now()
- Retourner action Odoo ir.actions.act_url vers signed_url
4) Sécurité (Record Rules) — “hériter du Case”
4.1 Groupes (minimaux)
| Groupe | But |
|---|---|
| group_synergia_care_team | Intervenants (lecture/écriture sur leurs Cases) |
| group_synergia_supervision_med | Supervision médicale (lecture large + audit) |
| group_synergia_backoffice_ops | Backoffice (accès limité, idéalement sans contenu médical) |
| group_synergia_expert_external | Experts externes (accès via share package/portail, pas via Documents) |
4.2 Record rules (proposées)
A) x_medical_case
Règle Care Team : accès si user est owner ou assigné (via users)
| Élément | Valeur |
|---|---|
| Modèle | x_medical_case |
| Group | group_synergia_care_team |
| Domaine (domain_force) | `[' |
B) x_media_asset
Héritage strict par Case :
| Élément | Valeur |
|---|---|
| Modèle | x_media_asset |
| Group | group_synergia_care_team |
| Domaine | `[' |
C) documents.document
Limiter par folder du Case
- Si votre documents.folder est proprement ACLé par équipe, vous pouvez vous appuyer dessus.
- Sinon, ajoutez une règle de cohérence : document visible si folder lié à un Case accessible.
Dans la pratique Odoo Documents, la sécurité peut vite devenir subtile. Le plus robuste : folder Case + ACL strict + pas d’external sharing.
D) ir.attachment
Important : ne pas exposer les attachments au-delà du modèle parent.
- En standard, Odoo attache la sécurité à res_model/res_id.
- Donc : attacher le stub à x_media_asset (pas au Case) vous donne un contrôle plus granulaire.
5) “Share Package” minimal (pour l’expertise externe)
Même si vous ne le déployez pas tout de suite, voici le modèle minimal pour industrialiser.
5.1 x_share_package
| Champ | Type | Commentaire |
|---|---|---|
| name | Char | SHARE-2026-000012 |
| x_case_id | M2O | Pivot |
| x_expert_review_request_id | M2O | Source |
| x_recipient_email | Char | Destinataire |
| x_recipient_phone | Char | OTP |
| x_state | Selection | draft/sent/opened/revoked/expired |
| x_expires_at | Datetime | Expiration globale |
| x_media_asset_ids | M2M | Sélection d’assets |
| x_audit_log | Text/Json | Journal d’accès (résumé) |
Principe : l’expert ne passe pas par Documents. Il passe par portail/OTP → gateway → URLs signées.
6) Contrat API “Media Gateway” (signature / révocation / audit)
6.1 Endpoints
A) Signer une URL (lecture)
POST /v1/media/sign
Request JSON :
{ "provider": "supabase", "bucket": "synergia-media", "key": "cases/2026/000123/audio/call_abc.wav", "method": "GET", "ttl_seconds": 900, "content_disposition": "inline", "request_context": { "odoo_db": "prod", "user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }
Response JSON :
{ "signed_url": "https://....", "expires_at": "2026-01-02T17:10:00Z", "provider": "supabase" }
B) Signer une URL (upload PUT) — si vous uploadez depuis un client
POST /v1/media/sign
- method = "PUT"
- Retourne une signed URL PUT (ou POST policy selon provider)
C) Révoquer / bloquer un asset (optionnel)
POST /v1/media/revoke
Request :
{ "provider": "supabase", "key": "cases/2026/000123/audio/call_abc.wav", "reason": "consent_revoked" }
Response :
{ "status": "ok" }
D) Audit (optionnel)
POST /v1/audit/event
Permet d’enregistrer : user, asset, action, timestamp, IP, etc.
7) Module minimal (structure) — si vous voulez “propre et durable”
7.1 Contenu du module
| Fichier | Rôle |
|---|---|
| models/x_media_asset.py | méthode action_open_media() + helpers |
| models/x_medical_case.py | création folder (si pas Studio) |
| security/ir.model.access.csv | ACL |
| security/rules.xml | record rules |
| views/x_media_asset_views.xml | bouton + smart buttons |
| data/server_actions.xml | actions auto (stub + documents.document) |
| controllers/media_proxy.py (optionnel) | si vous proxyfiez au lieu d’ouvrir URL |
7.2 Décision : ouvrir URL signée directe vs proxy
- Directe (recommandée) : Odoo renvoie act_url vers signed URL
- Proxy : Odoo sert comme proxy → plus complexe, mais contrôle supplémentaire
8) Checklist “prête à implémenter” (ordre recommandé)
- Créer modèles + champs (Studio)
- Ajouter x_care_team_user_ids sur Case (si possible) et remplir automatiquement
- Ajouter record rules Case + Asset
- Automatisation : créer folder Documents au Case
- Automatisation : à x_media_asset.ready, créer stub ir.attachment(type=url) + documents.document
- Ajouter bouton “Ouvrir média” sur Asset (module minimal recommandé)
- Déployer Media Gateway (signature GET/PUT)
- Tests : accès Care Team / supervision / backoffice, et expiration URL
9) Ce que je vous propose comme “pack suivant” (si vous continuez)
Je peux vous fournir, dans le format que vous utilisez déjà (très structuré) :
- Table complète Studio (champs + vues + actions) prête à copier dans votre cahier des charges
- XML record rules + ACL (exact)
-
Spéc technique Media Gateway (FastAPI ou Node) avec :
- auth service-to-service (HMAC / JWT)
- logs d’audit
- politique TTL par scope (team/expert/partner)
- option “watermark” pour PDF (si besoin)
Notre stockage externe cible est Supabase Storage @Faniry
Parfait — avec Supabase Storage comme cible, le pattern “x_media_asset vérité + ir.attachment(type='url') stub + URLs signées TTL court” est très naturel, à condition de ne jamais signer depuis Odoo (pas de clé Supabase côté Odoo), mais via un Media Gateway.
Ci-dessous : architecture + conventions de stockage + contrat API + implémentation de référence (FastAPI, et une variante Node si vous préférez).
1) Supabase Storage — réglages recommandés
1.1 Buckets
| Bucket | Visibilité | Usage |
|---|---|---|
| synergia-media | private | audio/vidéo/images/HD, preuves, etc. |
| synergia-docs (optionnel) | private | PDF médicaux si vous ne les mettez pas dans Odoo |
Règle : buckets private, accès uniquement via signed URLs.
1.2 Convention de chemins (keys)
Ne mettez pas de PHI dans les paths.
Exemple robuste :
cases/{year}/{case_uid}/audio/{call_uuid}.wav cases/{year}/{case_uid}/images/{asset_uuid}.jpg cases/{year}/{case_uid}/pdf/{asset_uuid}.pdf
Où :
- case_uid = identifiant interne non parlant (ex: c_2f8a9c...)
- call_uuid = UUID FreeSWITCH
- asset_uuid = UUID généré à la création de x_media_asset
Dans Odoo, x_external_key = ce path.
2) Signature Supabase : ce que fait le Media Gateway
2.1 Lecture (GET) : signed URL
Supabase Storage fournit une URL signée (TTL en secondes). Le gateway renvoie :
- signed_url
- expires_at
2.2 Upload (PUT/POST) : signed upload URL (selon SDK)
Selon la version du SDK Supabase, vous avez :
- Signed upload URL (préférable si vous uploadez depuis un client web/mobile)
- ou upload côté backend (gateway) si vous centralisez
Dans votre cas VoIP/FreeSWITCH :
- l’audio est généralement déposé par votre infra (FreeSWITCH/worker) → le gateway peut uploader directement (ou votre worker utilise la clé service-role en réseau privé).
- la partie “sign upload” est surtout utile si des clients externes uploadent (ex: aidant via portail).
3) Objet Odoo : champs à utiliser avec Supabase
x_media_asset
| Champ | Valeur Supabase |
|---|---|
| x_external_provider | supabase |
| x_external_bucket (ajout conseillé) | synergia-media |
| x_external_key | cases/2026/<case_uid>/audio/<call_uuid>.wav |
| x_source | external |
| x_status | pending → ready quand l’objet est présent |
| x_checksum_sha256 | à calculer côté worker (idéal) |
| x_stub_attachment_id | ir.attachment(type='url') |
J’ajoute x_external_bucket : utile si vous multipliez les buckets.
4) Contrat API Media Gateway (spéc exact)
4.1 Auth
- Appel Odoo → Gateway avec un HMAC ou JWT service-to-service.
- Le gateway détient SUPABASE_URL + SERVICE_ROLE_KEY (jamais dans Odoo).
4.2 Endpoint “sign GET”
POST /v1/media/sign-get
Request
{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d...-call.wav", "ttl_seconds": 900, "context": { "odoo_user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }
Response
{ "signed_url": "https://<project>.supabase.co/storage/v1/object/sign/....", "expires_at": "2026-01-02T18:45:00Z" }
4.3 Endpoint “check exists” (optionnel mais pratique)
POST /v1/media/exists
Request
{ "bucket": "synergia-media", "key": "cases/2026/.../call.wav" }
Response
{ "exists": true, "size_bytes": 12345678, "mime_type": "audio/wav" }
4.4 Endpoint “revoke” (optionnel)
Supabase signed URLs expirent ; la “révocation” forte se fait en pratique par :
- suppression/rotation de l’objet,
-
ou changement de key (move) si vous voulez invalider immédiatement.
Le gateway peut proposer :
POST /v1/media/revoke → move vers un “quarantine/” + mise à jour Odoo.
5) Cycle exact côté Odoo (ce que vous implémentez)
A2 — “Si média entrant (audio)”
-
Créer x_media_asset dès l’événement d’appel (même si fichier pas final)
- x_status='pending'
- x_external_bucket='synergia-media'
- x_external_key=<path cible final>
-
Quand l’audio est uploadé par votre worker FreeSWITCH :
- update x_status='ready', x_size_bytes, x_mime_type, x_checksum_sha256
-
Automated Action : si ready et pas de stub
- créer ir.attachment(type='url') attaché à x_media_asset
- créer documents.document dans le folder du Case (si vous indexez)
-
Bouton “Ouvrir média”
- Odoo appelle POST /v1/media/sign-get
- met à jour stub.url = signed_url
- ouvre l’URL
6) Implémentation de référence — FastAPI (gateway)
Code minimal (à adapter à votre auth et logs). Le point clé : service_role_key côté gateway uniquement.
# app.py import os from datetime import datetime, timezone, timedelta from fastapi import FastAPI, HTTPException, Header from pydantic import BaseModel, Field from supabase import create_client, Client SUPABASE_URL = os.environ["SUPABASE_URL"] SUPABASE_SERVICE_ROLE_KEY = os.environ["SUPABASE_SERVICE_ROLE_KEY"] sb: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) app = FastAPI(title="Synergia Media Gateway") # ---- Request/Response models class SignGetReq(BaseModel): bucket: str key: str ttl_seconds: int = Field(default=900, ge=60, le=3600) context: dict = Field(default_factory=dict) class SignGetResp(BaseModel): signed_url: str expires_at: str def require_internal_auth(x_synergia_sig: str | None): # TODO: vérifier HMAC/JWT, timestamp, anti-replay if not x_synergia_sig: raise HTTPException(status_code=401, detail="Missing signature") @app.post("/v1/media/sign-get", response_model=SignGetResp) def sign_get(req: SignGetReq, x_synergia_sig: str | None = Header(default=None)): require_internal_auth(x_synergia_sig) # Signed URL (download) res = sb.storage.from_(req.bucket).create_signed_url(req.key, req.ttl_seconds) if not res or not res.get("signedURL"): raise HTTPException(status_code=404, detail="Cannot sign URL (missing object or invalid key)") signed_url = res["signedURL"] expires_at = (datetime.now(timezone.utc) + timedelta(seconds=req.ttl_seconds)).isoformat() # TODO: audit log (req.context + signed_url hash + expires_at) return SignGetResp(signed_url=signed_url, expires_at=expires_at)
Variables d’environnement
- SUPABASE_URL
- SUPABASE_SERVICE_ROLE_KEY
Déploiement
- idéalement dans un VPC / réseau privé
- logs structurés (audit) : odoo_user_id, case_id, asset_id, reason, ip
7) Variante Node.js (si vous préférez)
import express from "express"; import { createClient } from "@supabase/supabase-js"; const app = express(); app.use(express.json()); const supabaseUrl = process.env.SUPABASE_URL; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const sb = createClient(supabaseUrl, serviceRoleKey); function requireInternalAuth(req, res, next) { // TODO HMAC/JWT if (!req.header("x-synergia-sig")) return res.status(401).json({ error: "Missing signature" }); next(); } app.post("/v1/media/sign-get", requireInternalAuth, async (req, res) => { const { bucket, key, ttl_seconds = 900, context = {} } = req.body; const { data, error } = await sb.storage.from(bucket).createSignedUrl(key, ttl_seconds); if (error || !data?.signedUrl) return res.status(404).json({ error: "Cannot sign URL" }); const expiresAt = new Date(Date.now() + ttl_seconds * 1000).toISOString(); // TODO audit log res.json({ signed_url: data.signedUrl, expires_at: expiresAt }); }); app.listen(8080);
8) Détails importants (sinon vous aurez des incidents)
8.1 TTL et UX
- TTL typique : 900s (15 min) pour lecture
- Si l’utilisateur garde l’onglet ouvert : au prochain clic → regen
8.2 Pas d’URL signée stockée “comme vérité”
- Dans Odoo, ir.attachment.url est une vue (éphémère)
- La vérité : bucket + key
8.3 Auditabilité
- Log côté gateway : who/what/when/why
- Log côté Odoo : message_post sur le Case ou un x_case_event (“media_opened”, “share_created”)
8.4 “Révocation instantanée”
- Supabase signed URL ne se “révoque” pas individuellement ; elle expire.
- Pour couper immédiatement : move/delete l’objet, ou rotate le path (move) + update Odoo.
9) Ce que je vous propose maintenant (prochain livrable utile)
Je peux vous livrer en un seul bloc (copiable) :
- Tables Studio complètes (x_external_bucket, x_stub_attachment_id, actions, vues)
- Règles d’accès exactes (Case → Asset → Documents)
-
Le Media Gateway prêt prod (FastAPI ou Node) avec :
- auth HMAC (timestamp + nonce)
- anti-replay
- logs JSON
- endpoint exists (HEAD)
- endpoint move-to-quarantine (révocation forte)
Dites-moi simplement si vous voulez que je parte sur FastAPI (souvent le plus rapide à stabiliser) ou Node.js (si votre équipe est plus JS).
Annexes : est ce que FastAPI (souvent le plus rapide à stabiliser) ou Node.js
Avec un FreeSWITCH dans la chaîne, le choix FastAPI vs Node.js ne doit pas se faire “par FreeSWITCH”, mais par l’endroit où vous allez piloter les événements téléphonie et le niveau de concurrence I/O (ESL, webhooks, uploads, signatures, audit). FreeSWITCH s’intègre très bien avec les deux.
Ma recommandation pragmatique, pour Synergia et vos contraintes (MVP robuste, traçabilité, time-to-market, charge I/O, équipe déjà très React/Node) :
Recommandation
1) Si vous avez déjà (ou allez avoir) un “orchestrateur téléphonie” en Node (ESL + webhooks)
→ Choisissez Node.js pour le Media Gateway.
Pourquoi :
- L’ESL (Event Socket Library) de FreeSWITCH est très fréquemment piloté en Node (event-driven, websockets, streaming I/O).
-
Vous aurez souvent un service Node “Voice Orchestrator” qui :
- consomme les événements (answered/bridge/hangup/recording, etc.)
- déclenche l’enregistrement, nommage, post-processing
- pousse dans Odoo (JSON-RPC) et met à jour x_media_asset
- Dans ce cas, mettre signature Supabase + audit + endpoints dans le même runtime (Node) réduit l’intégration, la latence et la surface d’erreur.
Architecture typique recommandée :
- Service A (Node) : Voice Orchestrator (ESL, CDR-like events, workers)
- Service B (Node) : Media Gateway (sign-get, exists, revoke/move quarantine)
- Ou un seul service Node “Voice+Media” au MVP, puis split ensuite.
Décision rapide (grille simple)
| Critère | Si c’est votre cas | Choix conseillé |
|---|---|---|
| Vous pilotez FreeSWITCH via ESL en Node (ou prévoyez de le faire) | Oui | Node.js |
| Votre équipe est majoritairement JS/TS (React/Node) | Oui | Node.js |
| Vous voulez un service “security/storage” isolé, très strict, typé, facile à auditer | Oui | FastAPI |
| Vous prévoyez beaucoup de workflows backend Python (IA, traitement audio, pipelines) dans le même service | Oui | FastAPI |
| MVP le plus rapide avec moins de friction d’intégration | Généralement côté JS chez vous | Node.js |
Ma recommandation pour votre contexte concret
Vu vos échanges récents : vous êtes fortement React/Node, vous travaillez sur IVR/FreeSWITCH/WebRTC, vous parlez de contrôleurs Node.js ESL, et votre logique “events → Odoo → assets” est naturellement event-driven.
Donc : je recommande Node.js pour le Media Gateway, et je garderais FastAPI plutôt pour les traitements IA “lourds” (transcription, résumé, RAG, scoring) si vous en avez besoin.
Pattern cible “propre”
- Node (Voice Orchestrator) : ESL + webhooks + state machine call/recording + push Odoo
- Node (Media Gateway) : sign-get / exists / move-to-quarantine / audit-log
- Odoo : vérité métier (x_medical_case, x_media_asset, x_expert_review_request)
- Supabase Storage : objets privés
Point de vigilance (important)
Quel que soit le langage :
- ne mettez jamais la service_role_key Supabase dans Odoo
- mettez le gateway derrière auth service-to-service (HMAC/JWT + anti-replay)
- loggez toute signature (qui/quoi/pourquoi) pour audit
Voici une version Node.js “prête prod MVP” du Media Gateway Supabase (Express), avec :
- Auth HMAC (timestamp + nonce + anti-replay)
- Endpoints : sign-get, exists, move-to-quarantine
- Logs JSON (audit)
- Payloads exacts à appeler depuis Odoo
- Recommandations de déploiement et d’exploitation
1) Contrat d’auth HMAC (service-to-service)
1.1 En-têtes requis
- x-synergia-ts : timestamp Unix (secondes)
- x-synergia-nonce : UUID/nonce unique
- x-synergia-sig : signature HMAC SHA-256
- content-type: application/json
1.2 Canonical string signée
On signe exactement :
TS\nNONCE\nMETHOD\nPATH\nBODY_SHA256_HEX
- TS = x-synergia-ts
- NONCE = x-synergia-nonce
- METHOD = POST
- PATH = /v1/media/sign-get etc.
- BODY_SHA256_HEX = sha256 du body JSON brut (string)
Signature :
- sig = hex(hmac_sha256(SYNERGIA_HMAC_SECRET, canonical_string))
1.3 Anti-replay
- Fenêtre de validité : ±120 secondes
- Cache nonce : TTL 5 minutes (Redis recommandé ; fallback mémoire possible en MVP)
2) Endpoints
2.1 POST /v1/media/sign-get
Request
{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "ttl_seconds": 900, "content_disposition": "inline", "context": { "odoo_db": "prod", "odoo_user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }
Response
{ "signed_url": "https://<project>.supabase.co/storage/v1/object/sign/...", "expires_at": "2026-01-02T19:10:00.000Z" }
2.2 POST /v1/media/exists
Request
{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "context": { "asset_id": 789 } }
Response
{ "exists": true }
Note : Supabase Storage ne donne pas toujours un HEAD simple via SDK ; la méthode MVP la plus robuste est un download() et fermeture immédiate (coûte un peu). En prod, on peut passer par l’API REST Storage ou stocker size/mime côté worker lors de l’upload.
2.3 POST /v1/media/move-to-quarantine
But : “révocation forte” (coupure immédiate) en déplaçant l’objet vers un chemin de quarantaine.
Request
{ "bucket": "synergia-media", "from_key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "to_key": "quarantine/2026/01/02/asset_789_7b0d-call.wav", "context": { "asset_id": 789, "reason": "consent_revoked" } }
Response
{ "status": "ok" }
3) Implémentation Express (code complet MVP)
3.1 package.json
{ "name": "synergia-media-gateway", "version": "1.0.0", "type": "module", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "@supabase/supabase-js": "^2.49.1", "express": "^4.19.2", "pino": "^9.3.2", "pino-http": "^10.3.0" } }
3.2 server.js
import express from "express"; import crypto from "crypto"; import pino from "pino"; import pinoHttp from "pino-http"; import { createClient } from "@supabase/supabase-js"; const { PORT = "8080", SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SYNERGIA_HMAC_SECRET, NONCE_TTL_SECONDS = "300", MAX_SKEW_SECONDS = "120", } = process.env; if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !SYNERGIA_HMAC_SECRET) { throw new Error("Missing env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SYNERGIA_HMAC_SECRET"); } const log = pino({ level: "info" }); const app = express(); // IMPORTANT: conserver le body brut pour le hash sha256 app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString("utf8"); } })); app.use(pinoHttp({ logger: log })); const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, }); // // Nonce cache MVP (mémoire) // En prod : remplacer par Redis (recommandé). // const nonceCache = new Map(); // nonce -> expiresAtMs function cleanupNonces() { const now = Date.now(); for (const [nonce, exp] of nonceCache.entries()) { if (exp <= now) nonceCache.delete(nonce); } } setInterval(cleanupNonces, 30_000).unref(); function sha256Hex(s) { return crypto.createHash("sha256").update(s, "utf8").digest("hex"); } function hmacHex(secret, data) { return crypto.createHmac("sha256", secret).update(data, "utf8").digest("hex"); } function timingSafeEqualHex(a, b) { const aBuf = Buffer.from(a, "hex"); const bBuf = Buffer.from(b, "hex"); if (aBuf.length !== bBuf.length) return false; return crypto.timingSafeEqual(aBuf, bBuf); } function requireHmac(req, res, next) { const ts = req.header("x-synergia-ts"); const nonce = req.header("x-synergia-nonce"); const sig = req.header("x-synergia-sig"); if (!ts || !nonce || !sig) { return res.status(401).json({ error: "Missing auth headers" }); } const tsInt = parseInt(ts, 10); if (!Number.isFinite(tsInt)) { return res.status(401).json({ error: "Invalid timestamp" }); } const now = Math.floor(Date.now() / 1000); const maxSkew = parseInt(MAX_SKEW_SECONDS, 10); if (Math.abs(now - tsInt) > maxSkew) { return res.status(401).json({ error: "Timestamp skew too large" }); } // anti-replay const ttl = parseInt(NONCE_TTL_SECONDS, 10); const expMs = Date.now() + ttl * 1000; if (nonceCache.has(nonce)) { return res.status(401).json({ error: "Nonce replay detected" }); } nonceCache.set(nonce, expMs); const bodyRaw = req.rawBody ?? ""; const bodyHash = sha256Hex(bodyRaw); const canonical = `${ts}\n${nonce}\n${req.method}\n${req.path}\n${bodyHash}`; const expected = hmacHex(SYNERGIA_HMAC_SECRET, canonical); if (!timingSafeEqualHex(expected, sig)) { // purge nonce to avoid locking out on bad sig storms nonceCache.delete(nonce); return res.status(401).json({ error: "Bad signature" }); } req.authContext = { ts: tsInt, nonce }; next(); } function clampTtl(ttlSeconds) { const ttl = Math.max(60, Math.min(3600, Number(ttlSeconds || 900))); return Number.isFinite(ttl) ? ttl : 900; } function safeKey(key) { // MVP: validation minimaliste if (typeof key !== "string" || key.length < 3 || key.length > 1024) return false; if (key.includes("..")) return false; return true; } app.get("/health", (req, res) => res.json({ ok: true })); app.post("/v1/media/sign-get", requireHmac, async (req, res) => { const { bucket, key, ttl_seconds = 900, content_disposition = "inline", context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(key)) { return res.status(400).json({ error: "Invalid bucket/key" }); } const ttl = clampTtl(ttl_seconds); // Audit log (ne loggez pas d’infos PHI) req.log.info({ event: "sign_get", bucket, key_hash: sha256Hex(key), ttl, context, }); try { // Supabase JS v2: createSignedUrl const { data, error } = await sb.storage.from(bucket).createSignedUrl(key, ttl); if (error || !data?.signedUrl) { return res.status(404).json({ error: "Cannot sign URL" }); } // Option content-disposition : Supabase n’expose pas toujours ce paramètre // de manière uniforme. Si besoin, vous passerez par un proxy de download. const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); return res.json({ signed_url: data.signedUrl, expires_at: expiresAt }); } catch (e) { req.log.error({ event: "sign_get_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.post("/v1/media/exists", requireHmac, async (req, res) => { const { bucket, key, context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(key)) { return res.status(400).json({ error: "Invalid bucket/key" }); } req.log.info({ event: "exists_check", bucket, key_hash: sha256Hex(key), context }); try { // MVP: tenter un download (peut être coûteux). // Alternative: utiliser storage API REST pour HEAD/metadata. const { data, error } = await sb.storage.from(bucket).download(key); if (error || !data) { return res.json({ exists: false }); } // Fermer/consommer rapidement // data est un Blob (Node 18+). On ne lit pas le contenu. return res.json({ exists: true }); } catch (e) { req.log.error({ event: "exists_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.post("/v1/media/move-to-quarantine", requireHmac, async (req, res) => { const { bucket, from_key, to_key, context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(from_key) || !safeKey(to_key)) { return res.status(400).json({ error: "Invalid bucket/keys" }); } req.log.warn({ event: "move_to_quarantine", bucket, from_hash: sha256Hex(from_key), to_hash: sha256Hex(to_key), context }); try { const from = sb.storage.from(bucket); // copy then remove (move) : supabase storage API // Attention: selon versions, move() peut exister; sinon copy+remove. const { error: copyErr } = await from.copy(from_key, to_key); if (copyErr) return res.status(500).json({ error: "Copy failed" }); const { error: rmErr } = await from.remove([from_key]); if (rmErr) return res.status(500).json({ error: "Remove failed" }); return res.json({ status: "ok" }); } catch (e) { req.log.error({ event: "move_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.listen(parseInt(PORT, 10), () => { log.info({ msg: "Synergia Media Gateway started", port: PORT }); });
4) Variables d’environnement (prod)
| Variable | Description |
|---|---|
| SUPABASE_URL | URL projet Supabase |
| SUPABASE_SERVICE_ROLE_KEY | Clé service_role (jamais dans Odoo) |
| SYNERGIA_HMAC_SECRET | Secret HMAC (à stocker dans un vault) |
| PORT | 8080 |
| NONCE_TTL_SECONDS | 300 |
| MAX_SKEW_SECONDS | 120 |
5) Appel depuis Odoo (pseudo-spéc, côté module minimal)
5.1 Au clic “Ouvrir média”
- Construire body JSON : bucket, key, ttl_seconds, context
- Calculer body_raw (JSON string)
- body_hash = sha256(body_raw)
- canonical = ts + "\n" + nonce + "\n" + "POST" + "\n" + path + "\n" + body_hash
- sig = hmac_sha256(secret, canonical)
- POST vers gateway
- Mettre à jour ir.attachment.url = signed_url
- Retourner act_url vers signed_url
Si vous ne voulez pas implémenter HMAC côté Odoo (possible selon contraintes), vous pouvez passer par un petit “bridge” interne (n8n/worker) qui signe pour Odoo. Mais le plus propre est de signer côté module Odoo (secret stocké serveur) uniquement si votre Odoo est auto-hébergé et vous contrôlez le secret. Sur Odoo Online, évitez de stocker des secrets applicatifs.
6) Recommandation d’exploitation (très concrète)
6.1 Où déployer ce gateway
- Même zone / VPC que FreeSWITCH et vos workers (latence faible)
- Exposer publiquement uniquement si nécessaire (sinon privé)
- Si exposé : TLS + WAF + rate limit
6.2 Logs / audit
Le gateway log :
- event, bucket, key_hash, ttl, context (sans PHI)
- À corréler avec Odoo (asset_id, case_id)
6.3 Politique TTL
- team: 15 min
- expert: 5–10 min
- partner: 5 min + OTP/portail
Très clair : avec Odoo Online, la règle d’or est simple :
- Odoo Online ne doit détenir aucun secret (ni Supabase service role, ni HMAC interne).
- Donc Odoo Online ne doit pas signer (ni même appeler directement un endpoint qui nécessite une signature HMAC basée sur un secret stocké dans Odoo).
La bonne architecture est donc :
Recommandation cible (Odoo Online) : “Odoo → Redirector (Node) → Gateway (HMAC) → Supabase Signed URL”
Principe
- L’utilisateur clique “Ouvrir média” dans Odoo
- Odoo ouvre une URL vers votre service Node “Redirector” (public ou privé via VPN)
-
Le Redirector :
- vérifie un token one-time (sans secret côté Odoo)
- journalise (audit)
- appelle le Media Gateway (HMAC, secret côté Node)
- récupère une signed URL Supabase
- renvoie un 302 redirect vers la signed URL
Ainsi :
- Odoo ne fait que “rediriger” (aucun secret).
- Node fait la sécurité + la signature + l’audit.
- Supabase reste private, accès via signed URLs.
1) Modifs minimales côté Odoo (Odoo Studio)
Ajoutez 2 champs sur x_media_asset :
| Champ | Type | Rôle |
|---|---|---|
| x_open_token | Char | token à usage unique (opaque) |
| x_open_token_expires_at | Datetime | expiration du token (ex: now+10 min) |
Optionnel (utile audit) :
| Champ | Type | Rôle |
|---|---|---|
| x_last_opened_at | Datetime | dernière ouverture |
| x_open_count | Integer | compteur |
Bouton “Ouvrir média”
Dans Studio, faites un bouton qui ouvre une URL externe construite comme :
https://media.synergia.tld/open?asset_id=<id>&token=<x_open_token>
(ou incluez db si vous en avez besoin, mais évitez toute donnée sensible)
Le bouton “URL”/“Ouvrir lien” est exactement ce que Odoo Online sait faire proprement, sans secrets.
2) Qui génère x_open_token ? (côté Node, pas côté Odoo)
Moment de génération
Quand l’asset passe à ready (ou quand le stub est créé).
Votre orchestrateur Node (celui qui reçoit les événements FreeSWITCH et pousse dans Odoo) fait :
- Génère un token aléatoire :
- 32 bytes aléatoires, encodés base64url ou hex
- Stocke le hash du token côté Node (Redis recommandé) :
- clé : open_token_hash -> {asset_id, expires_at, policy}
- Écrit dans Odoo (JSON-RPC) :
- x_open_token = token_plain (oui, en clair dans Odoo, mais TTL court)
- x_open_token_expires_at = now + 10 min
Pourquoi token en clair dans Odoo ?
Parce qu’Odoo doit pouvoir le mettre dans l’URL du bouton.
La sécurité vient de :
- TTL court
- usage unique
- vérification côté Node
- pas de secret exposé
3) Service Node “Redirector” : endpoint /open
Spéc
GET /open?asset_id=789&token=...
Comportement
- Vérifie :
- token présent
- TTL non dépassé (en se basant sur Redis côté Node)
- token one-time (consommé dès usage)
- optionnel : vérifier que l’asset est ready
- Appelle le Media Gateway (HMAC) :
- sign-get(bucket, key, ttl=900)
- Renvoie 302 Location: <signed_url>
Effet UX
L’utilisateur clique dans Odoo → l’audio s’ouvre / se télécharge via URL signée, sans que Odoo ait signé quoi que ce soit.
4) Pourquoi c’est la meilleure approche pour Odoo Online
Avantages
- Zéro secret dans Odoo Online (conforme à vos contraintes)
- Vous gardez le HMAC + anti-replay sur le segment Node ↔ Gateway
- Gouvernance : vous centralisez audit, quotas, règles TTL, “revocation forte”
- Très simple côté Odoo (champs + bouton)
Points de vigilance (gérés par Node)
- Token one-time consommé dès la première utilisation
- TTL court (5–10 minutes)
- Logs d’audit (asset_id, case_id, user si disponible, IP)
- Rate limiting sur /open
5) Variante encore plus stricte (si vous voulez éviter token “en clair” dans Odoo)
C’est possible, mais plus complexe : Odoo ouvrirait une URL sans token, et le Redirector devrait authentifier l’utilisateur via Odoo (SSO/OAuth) ou via un “Odoo signed callback”. En Odoo Online, c’est généralement surdimensionné pour un MVP.
Pour un MVP “santé” robuste, le token one-time TTL court est le meilleur compromis.
6) Ce que je vous recommande de faire maintenant (ordre exact)
- Ajouter x_open_token, x_open_token_expires_at dans x_media_asset
- Créer le bouton “Ouvrir média” → URL https://media.../open?asset_id=...&token=...
-
Dans votre orchestrateur Node (FreeSWITCH → Odoo) :
- à asset.ready : générer token + écrire dans Odoo
- Déployer le Redirector /open (avec Redis)
- Conserver le Media Gateway HMAC pour signer Supabase
Si vous me confirmez le nom de domaine envisagé (ex: media.synergia.tld) et si vous avez déjà Redis disponible (oui/non), je vous fournis immédiatement :
- le code Node complet du Redirector /open (Express) avec Redis + one-time token,
- et la séquence JSON-RPC exacte côté orchestrateur pour écrire x_open_token dans Odoo Online.
ANNEXE : about SIP , about IVR classique pour FREESWITCH , et about FREESWITCH
IVR pour freeSwitch
quel est l'IVR open source fonctionnant le mieux avec freeswitch sachant que nous allons utiliser des services de STT et TTS et des services N8N ?
Modèle d'habitat
Des habitats avec une empreinte minimale sur la planète et un impact positif maximal sur la communauté locale.
Jardin biologique
Apprenez à utiliser des méthodes de jardinage biologique pour cultiver des fruits et légumes frais.