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 avecplatform-ops/0371. A relire quand on ajoute unput(): 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
| Prefixe | Ecrit par | access | Nom devinable | Duree de vie |
|---|---|---|---|---|
chat-attachments/<orgId>/<userId>/ | src/app/api/chat/upload/route.ts | public | non (addRandomSuffix) | 30 jours, cron cleanup-chat-attachments — + a la demande, voir plus bas |
uploads/<userId>/ | src/app/api/upload/route.ts | public | non (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 VercelBlobStorage | public | non (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 VercelBlobStorage | public | non (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 VercelBlobStorage | public | non (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.ts | public | non (addRandomSuffix) | permanent |
intelligence/screenshots/<domain>/ | src/services/algorithms/intelligence/probes/storefront-screenshot.ts | public | non depuis platform-ops/0533 (addRandomSuffix) — etait <domain>/<timestamp>.png, les deux moities publiques | permanent |
psi/… (rapports PageSpeed bruts) | src/services/jobs/handlers/fetch-psi.ts | public | selon la cle | permanent |
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 lelist(), et un test le couvre : undel()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-erasuredraine 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
(
prepareModelMessagesn'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.tsne 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'abaisserMAX_SIZE_BYTESplutot 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()
- 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.
addRandomSuffix: truesauf raison ecrite. Un chemin devinable sur un blob public est une liste ouverte.- 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.