Runbooks & opérationsVercel Blob — ce qui y vit, combien de temps, et ce que ca garantit

Vercel Blob — ce qui y vit, combien de temps, et ce que ca garantit

Pilier platform-ops. Ecrit avec platform-ops/0371. A relire quand on ajoute un put() : un prefixe sans ligne dans le tableau ci-dessous est un prefixe dont personne ne sait quand il expire.

Pilier platform-ops. Ecrit avec platform-ops/0371. A relire quand on ajoute un put() : un prefixe sans ligne dans le tableau ci-dessous est un prefixe dont personne ne sait quand il expire.

La phrase a ne pas mal lire

Aucun objet de ce depot n'est confidentiel. Chaque put() du repo passe access: "public". Le seul chemin d'ecriture qui existe aujourd'hui produit une URL que quiconque la detient peut lire, sans session et sans controle d'autorisation.

Ce que l'entropie du nom apporte est reel mais limite : addRandomSuffix rend l'URL non enumerable, pas non partageable. Elle fuit par un copier-coller, un ticket de support, un historique de navigateur, un presse-papier, un log de proxy.

Donc le levier disponible n'est pas la confidentialite, c'est la duree. C'est ce que ce document tient a jour.

Les prefixes, et leur cycle de vie

PrefixeEcrit paraccessNom devinableDuree de vie
chat-attachments/<orgId>/<userId>/src/app/api/chat/upload/route.tspublicnon (addRandomSuffix)30 jours, cron cleanup-chat-attachments — + a la demande, voir plus bas
uploads/<userId>/src/app/api/upload/route.tspublicnon (addRandomSuffix)permanent — logos d'organisation/boutique et avatars (images uniquement, 4 Mo). Contenu destine a etre public par nature : pas un candidat a la revocation
marketplace/<userId>/<icon|screenshot>/src/app/api/vendor/marketplace/upload/route.ts via VercelBlobStoragepublicnon (addRandomSuffix)permanent — icones et captures de listing vendeur. Meme raisonnement : contenu public par nature
stores/<storeId>/studio/features/ai/orchestrator/runtime/handler-tools-build.ts via VercelBlobStoragepublicnon (addRandomSuffix)permanent — rendus @Atlas Studio (creatives payantes, proprietaires du marchand). Pas regarde ici. Le prefixe studio/unassigned/ qui figurait sur cette ligne a ete supprime par ai-platform/2782 : une generation sans boutique est refusee avant toute depense, au lieu d'ecrire sous un prefixe qui n'avait aucun lecteur et aucune purge. Les objets deja ecrits sous lui restent, et c'est data-platform/2791 qui les balaie
stores/<storeId>/agent-screenshots/features/ai/tools/browser/screenshot.ts via VercelBlobStoragepublicnon (addRandomSuffix)permanent — captures prises par l'agent @Atlas en naviguant l'admin d'un marchand. Jamais regarde jusqu'ici : peut montrer des donnees business (commandes, chiffres, e-mails) selon ce qui etait a l'ecran
scans/<scanId>/src/features/tracking/lib/artifacts.tspublicnon (addRandomSuffix)permanent
intelligence/screenshots/<domain>/src/services/algorithms/intelligence/probes/storefront-screenshot.tspublicnon depuis platform-ops/0533 (addRandomSuffix) — etait <domain>/<timestamp>.png, les deux moities publiquespermanent
psi/… (rapports PageSpeed bruts)src/services/jobs/handlers/fetch-psi.tspublicselon la clepermanent

Cette ligne a longtemps decrit uploads/ comme portant « les pieces jointes de deals marketplace, referencees par DealMessage.attachments » — faux : DealMessage.attachments est cree vide partout (attachments: [], src/services/marketplace/seller.ts), DealThread.ndaUrl / loiUrl / apaUrl n'ont aucun ecrivain nulle part dans le depot, et le composeur de messages de deal (~/deals/[id]/_components/deal-message-composer.tsx) n'a meme pas de controle pour joindre un fichier. Le champ existe en base, la fonctionnalite n'existe pas — meme defaut que celui que ce fichier lui-meme existe pour eviter, applique a une ligne de ce fichier. Trois prefixes en plus etaient absents du tableau entier : marketplace/, stores/<id>/studio et stores/<id>/agent-screenshots, ce dernier n'ayant jamais ete regarde.

Aucune de ces sept lignes n'est un oubli du present document : ce sont des faits. Nommer un prefixe est le minimum pour que « on a corrige le Blob » ne se lise pas comme « tout le Blob ». agent-screenshots/ en particulier merite un regard : c'est la seule ligne de ce tableau dont personne n'a encore etabli si son contenu est sensible.

Ce que le cron cleanup-chat-attachments garantit VRAIMENT

Route : src/app/api/cron/cleanup-chat-attachments/route.ts. Cadence : quotidienne, 03:20 UTC.

Ce qu'il garantit :

  • une piece jointe binaire du chat cesse d'exister 30 jours apres son depot. Une URL qui a fuite expire, au lieu d'etre permanente ;
  • il existe enfin un chemin d'effacement mecanique pour ce prefixe, ce qu'il n'y avait pas du tout ;
  • il ne touche que chat-attachments/. Le filtre de prefixe est re-applique cote code apres le list(), et un test le couvre : un del() ne se rejoue pas.

Ce qu'il ne garantit PAS, et qu'il ne faut pas lui preter :

  • la piece jointe n'est pas confidentielle pendant ces 30 jours. Elle est lisible par toute personne qui detient l'URL ;
  • il n'y a pas de revocation a la demande. Supprimer une conversation ne supprime pas l'objet ; seul le temps le fait ;
  • ce n'est pas un chemin RGPD complet. Le cron gdpr-erasure draine les effacements client Shopify — un client du marchand — alors qu'une piece jointe de chat appartient au marchand lui-meme. Les deux ne se recouvrent pas, et brancher l'un sur l'autre effacerait la mauvaise chose. Ce qui manque cote utilisateur (« efface mes pieces jointes maintenant ») est un item a part.

Suppression a la demande (platform-ops/0533)

Le cron ci-dessus borne la duree ; il ne dit rien de ce qui se passe avant l'expiration. Jusqu'a platform-ops/0533, supprimer une conversation ne supprimait pas ses pieces jointes : le cascade Postgres (Conversation.messages, onDelete: Cascade) efface les lignes Message, et rien ne relie une ligne Message a un objet Blob par une cle — chat-attachments/<orgId>/<userId>/<timestamp>.<ext> ne porte pas de conversationId. Un marchand qui deposait une facture par erreur et supprimait le fil n'avait aucun recours avant les 30 jours du cron.

src/features/ai/chat/lib/attachment-cleanup.ts ferme ce trou pour les deux points de suppression qui existent (deleteConversation et bulkDeleteConversations, admin/ai/conversations/_actions/index.ts) : il lit Message.parts (le seul endroit ou l'URL Blob d'une piece jointe est effectivement stockee, comme FileUIPart.url) avant que le cascade n'efface les lignes, puis appelle del() en best-effort une fois la suppression de la conversation deja commitee. Un echec du del() ne fait pas echouer l'action admin : la conversation reste supprimee, l'objet Blob reste orphelin jusqu'au passage du cron suivant, et l'echec est journalise.

Ce que ca ne couvre PAS : il n'existe aujourd'hui aucune suppression de conversation cote marchand (seul /admin/ai/conversations supprime), donc ce chemin ne s'exerce que depuis l'admin. Le jour ou une suppression merchant-facing existe, elle doit appeler les memes deux fonctions — c'est exactement pour ca qu'elles sont exportees separement du call site plutot qu'enfouies dans l'action.

Pourquoi pas un blob prive

@vercel/blob@2.3.1 sait faire : access: "private" a l'ecriture, get(pathname, { access: "private" }) a la lecture. Ce n'est pas le bon levier ici, et la raison n'a rien a voir avec le plan Vercel.

Le lecteur de cette URL n'est pas nous, c'est le fournisseur de modele. buildFileParts (src/features/ai/orchestrator/runtime/handler-helpers.ts) passe au SDK data: new URL(upload.url), et c'est le fournisseur qui va chercher l'objet. Un objet prive lui repond 401.

Passer en prive suppose donc que le serveur recupere les octets et les inline dans la charge envoyee au fournisseur : une modification du chemin de requete ai-platform. C'est une vraie option, et c'est une decision separee — pas quelque chose qu'un cron tranche. Le chiffre que platform-ops/0533 demandait avant d'ecrire ce code :

Le cout, chiffre

  • Gonflement base64 : exactement 4/3, pas une estimation. Pour le plafond actuel de MAX_SIZE_BYTES (25 Mo, api/chat/upload/route.ts), une piece jointe au maximum encode en ~33,3 Mo de texte dans la charge envoyee au fournisseur.
  • Ce cout est paye a CHAQUE occurrence de l'attachement dans le fil, pas une fois pour toutes. Le commentaire du cron affirme que le modele "ne relit pas les pieces jointes de l'historique" — vrai pour l'AFFICHAGE (prepareModelMessages n'injecte que la requete courante), mais un utilisateur qui rattache la meme image a un tour suivant refait payer le gonflement, exactement comme un fetch d'URL le referait aujourd'hui. Le passage en prive ne change donc pas la FREQUENCE du cout, il change QUI le paye et QUAND :
    • aujourd'hui : le fournisseur va chercher l'objet lui-meme, en parallele de notre requete — le temps de ce fetch n'apparait dans aucune metrique a nous ;
    • en prive : NOTRE serveur fait d'abord un GET Blob (latence + egress a notre charge), PUIS encode et envoie ~33,3 Mo dans la requete au fournisseur — un aller-retour serie de plus, sur le chemin chaud du chat, avant meme que le fournisseur commence a repondre.
  • Le plafond de charge du fournisseur n'est pas documente dans ce depot (src/config/ai-models.ts ne porte aucune limite de taille de requete par modele). C'est la seule case du calcul qui reste a verifier au moment de la decision, pas a estimer ici : les limites publiees par Anthropic / OpenAI / Google evoluent et different par modele, et une charge de ~33,3 Mo pourrait simplement etre refusee par certains, ce qui rouvrirait la question d'abaisser MAX_SIZE_BYTES plutot que de pretendre que le passage en prive fonctionne pour toute taille acceptee aujourd'hui.

Recommandation, pas une decision : le jeton actuel (30 jours, addRandomSuffix, pas d'enumeration) protege deja contre la decouverte au hasard ; ce que le blob prive achete est une protection contre le copier-coller d'une URL qui a fuite — un gain reel mais etroit — au prix d'une latence serie ajoutee sur CHAQUE tour portant une piece jointe et d'un risque de rejet fournisseur non verifie. A ce chiffre, la moitie 2 (suppression a la demande, ci-dessus) rapporte plus par unite de risque que le passage en prive. La decision reste au proprietaire du produit.

Ajouter un put()

  1. Choisir le prefixe, et l'ajouter au tableau ci-dessus avec sa duree de vie. « permanent » est une reponse acceptable ; « je ne sais pas » ne l'est pas.
  2. addRandomSuffix: true sauf raison ecrite. Un chemin devinable sur un blob public est une liste ouverte.
  3. Si la duree de vie est bornee, elle est bornee par un cron, pas par une intention. Vercel Blob n'a pas de TTL par objet : ce qui n'est pas supprime par du code n'est pas supprime.