Runbooks OrbitRunbook — Stockage privé des fichiers

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

SujetRègle
Accès par défautprivé. Une seule variable, ORBIT_BLOB_ACCESS (private ou public, private si absente), déclarée dans apps/web/src/env/schema.ts
Écriturestoutes avec l'accès configuré (packages/engine/src/generation/blob.ts) ; la clé n'est jamais renvoyée au navigateur
Lectures serveurget(chemin, { access: "private" }) avec BLOB_READ_WRITE_TOKEN ; jamais une requête non authentifiée sur une URL Blob
Ce que reçoit un navigateurjamais 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/filesporte 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êtesCache-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ésbrain/<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)
Gardeapps/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éversementrefusé 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.

  1. 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 en orbit-easyconnector-app-public-old si 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.
  2. 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.
  3. Variables. Ne pas poser ORBIT_BLOB_ACCESS (le défaut est private) ; si une valeur public existe quelque part, la supprimer. Dans GitHub (dépôt Orbit, Settings → Secrets and variables → Actions) : mettre à jour le secret BLOB_READ_WRITE_TOKEN avec le jeton du nouveau store (le runner de rendu publie avec lui) ; ne pas créer la variable ORBIT_BLOB_ACCESS du dépôt, ou la mettre à private.
  4. 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.
  5. Vérifier que rien n'est public. Dans les outils du navigateur, copier l'adresse d'une image : elle commence par /api/files/, jamais par https://….blob.vercel-storage.com. Ouverte dans une fenêtre privée (sans connexion), elle répond 401. Les logs ne montrent aucun blob.access_mismatch.
  6. 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.
  7. 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, et private-blob.test.ts le vérifie pour le service de génération.
  • Elle ne met rien en cache partagé : Cache-Control: private, no-store. Vercel déconseille s-maxage pour 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 appeler purgeSpaceFiles(blob, spaceId) (apps/web/src/modules/brain/spaces.ts), qui liste et supprime brain/<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 (list et del du SDK, avec le jeton).