Runbook — Stockage privé des fichiers
title: Runbook — Stockage privé des fichiers (Blob) description: Les fichiers des clients (Brain, références, rendus) ne sont lisibles que par une route authentifiée : créer le store Blob privé, le relier, redéployer, vérifier, et lire l'erreur blobAccessMismatch.
Les fichiers que les clients confient à Orbit (PDF et images du Brain, références téléversées, rendus) vivent sur Vercel Blob. Jusqu'au 3 octobre 2026 le store était public : quiconque avait l'URL d'un fichier le lisait, sans connexion. Orbit n'avait aucun utilisateur et le store ne contient que des fichiers de test orphelins (la base a été réinitialisée) : il n'y a rien à migrer, il faut seulement changer de store avant les premiers vrais clients.
Ce que fait le code
| Sujet | Règle |
|---|---|
| Accès par défaut | privé. Une seule variable, ORBIT_BLOB_ACCESS (private ou public, private si absente), déclarée dans apps/web/src/env/schema.ts |
| Écritures | toutes avec l'accès configuré (packages/engine/src/generation/blob.ts) ; la clé n'est jamais renvoyée au navigateur |
| Lectures serveur | get(chemin, { access: "private" }) avec BLOB_READ_WRITE_TOKEN ; jamais une requête non authentifiée sur une URL Blob |
| Ce que reçoit un navigateur | jamais une URL Blob. displayUrl (apps/web/src/lib/blob-files.ts) la transforme en /api/files/<chemin>, servie par apps/web/src/app/api/files/[...key]/route.ts |
La route /api/files | porte de session (401 sans connexion, membre et permission relus en base), puis tenant (le produit, l'espace Brain ou le propriétaire du fichier doit appartenir au workspace du membre, sinon 404, la même réponse qu'un fichier inexistant), puis lecteur (ses propres fichiers, ceux de l'équipe avec costs.view-team, la file QC avec review) |
| En-têtes | Cache-Control: private, no-store, X-Content-Type-Options: nosniff, type issu de la ligne en base (jamais deviné ; hors liste : téléchargement), CSP sandbox (un SVG ouvert directement n'exécute rien), Range honoré (206 et 416) pour que <video> puisse se déplacer |
| Clés | brain/<espace>/<id aléatoire de 80 bits>/file.<ext> : le nom de fichier du client n'est plus dans la clé (il reste dans sourceRef) |
| Un fournisseur qui doit lire une entrée (image de départ d'une vidéo, visage d'un lipsync) | URL signée et expirante (30 minutes, BlobStore.providerUrl), la seule exception, posée à un seul endroit (withProviderUrls) |
| Garde | apps/web/src/test/private-blob.test.ts échoue si un access: "public" réapparaît, si le SDK serveur est importé ailleurs que dans blob.ts, ou si une URL Blob est écrite en dur |
| Stockage local (dev, e2e) | inchangé : local-blob:<chemin> dans .blob/, servi par la même route /api/files |
La CSP n'ouvre plus aucun hôte Blob au navigateur (img-src, media-src : 'self'
seulement) ; seul le point d'envoi du SDK (https://vercel.com/api/blob/) reste dans
connect-src pour le téléversement direct d'un rush.
Un store, un accès, fixé à la création
Vercel fixe l'accès d'un store Blob à sa création : on ne bascule pas un store public
en privé. Un store et ORBIT_BLOB_ACCESS qui ne concordent pas échouent par leur
nom, jamais en silence : l'erreur blobAccessMismatch.
| Où | Ce qu'on voit |
|---|---|
| Logs Vercel | {"level":"error","event":"blob.access_mismatch","configured":"private",…} (une fois par instance) |
| Une génération, un téléversement | refusé avant l'appel au fournisseur et avant toute retenue : « Le stockage Blob ne correspond pas à l'accès configuré (blobAccessMismatch…) » ; rien n'est débité |
| Import d'un fichier dans le Brain | « Le stockage des fichiers est mal configuré (blobAccessMismatch…) » |
GET /api/files/… | 503 avec {"error":{"code":"blobAccessMismatch","message":"…"}} |
Étape Publish assets du workflow render.yml | échec nommé blobAccessMismatch (ORBIT_BLOB_ACCESS=…) |
Correction : relier un store créé avec l'accès voulu, ou poser ORBIT_BLOB_ACCESS à
l'accès du store relié (déconseillé : public redonne des fichiers lisibles par URL).
Actions du propriétaire, dans cet ordre
Le code est prêt à servir un store privé et refuse (par son nom) un store public : l'ordre évite qu'une écriture de production tombe sur le mauvais store.
- Créer un NOUVEAU store privé. Vercel → projet
orbit-easyconnector-app→ onglet Storage → Create Storage → Blob → accès Private. Nom : le slug de l'app (orbit-easyconnector-app, règle de nommage d'AGENTS.md). Le store actuel porte déjà ce nom : renommer l'ancien enorbit-easyconnector-app-public-oldsi le tableau de bord le permet, sinon le supprimer d'abord (il ne contient que des fichiers de test) avant de créer le nouveau. - Le relier au projet pour l'environnement Preview d'abord. Vercel injecte
BLOB_READ_WRITE_TOKEN(et l'identifiant du store). Production garde l'ancien jeton tant que le nouveau n'y est pas relié : aucune coupure. - Variables. Ne pas poser
ORBIT_BLOB_ACCESS(le défaut estprivate) ; si une valeurpublicexiste quelque part, la supprimer. Dans GitHub (dépôt Orbit, Settings → Secrets and variables → Actions) : mettre à jour le secretBLOB_READ_WRITE_TOKENavec le jeton du nouveau store (le runner de rendu publie avec lui) ; ne pas créer la variableORBIT_BLOB_ACCESSdu dépôt, ou la mettre àprivate. - Redéployer la preview de la PR (un commit dont le message contient
[preview], ou Redeploy dans Vercel : un changement de variable n'atteint que les nouveaux déploiements). Vérifier, connecté : importer un PDF dans un Space du Brain, voir une image générée dans le cockpit, lire une vidéo et se déplacer dedans. - Vérifier que rien n'est public. Dans les outils du navigateur, copier l'adresse d'une
image : elle commence par
/api/files/, jamais parhttps://….blob.vercel-storage.com. Ouverte dans une fenêtre privée (sans connexion), elle répond 401. Les logs ne montrent aucunblob.access_mismatch. - Seulement ensuite, merger la PR sur
main. Avant le merge, relier le nouveau store à l'environnement Production (en détachant l'ancien) : le déploiement de production déclenché par le merge démarre avec le bon jeton. Si le code est déjà en ligne avec l'ancien store public, il échoue par son nom (blobAccessMismatch, rien d'écrit en public) jusqu'au redéploiement avec le nouveau jeton : sans conséquence tant qu'Orbit n'a pas d'utilisateurs. - Après le merge : refaire les vérifications 4 et 5 sur
https://orbit.easyconnector.app, puis supprimer l'ancien store public (Storage → l'ancien store → Delete).
Les mêmes variables s'appliquent à un nouveau produit de la famille : voir le Blueprint, ligne « Fichiers privés ».
Ce que la route ne fait pas (et pourquoi)
- Elle ne remplace pas la porte de chaque action : une action serveur qui renvoie une URL de
fichier la passe par
displayUrl, etprivate-blob.test.tsle vérifie pour le service de génération. - Elle ne met rien en cache partagé :
Cache-Control: private, no-store. Vercel déconseilles-maxagepour des blobs privés (un défaut de configuration exposerait un fichier à un autre utilisateur). - Servir de gros fichiers par une fonction coûte du transfert de données : la documentation Vercel déconseille les fichiers de plus de 100 Mo sur un store privé, et le plus gros fichier d'Orbit (un clip) est plafonné à 100 Mo.
Suppression et nettoyage
- Un fichier du Brain est supprimé avec sa mémoire (
deleteMemory). Il n'existe pas encore de suppression d'un Space ni d'un compte : quand elle sera écrite, elle devra appelerpurgeSpaceFiles(blob, spaceId)(apps/web/src/modules/brain/spaces.ts), qui liste et supprimebrain/<espace>/seulement. - La rétention de 30 jours, la purge d'un produit et la suppression d'un rendu passent par le
même store et suppriment ses objets (
listetdeldu SDK, avec le jeton).