ADR-0016 · Service tiers = connecteur
title: ADR-0016 — Chaque service tiers du studio est un connecteur description: Chaque service tiers est un connecteur : port, registre, crédit et état séparés.
Statut
Proposé · 2026-09-27
Remplace ou amende : Rien. Amende la carte de migration §5 (un crédit de fournisseur cesse d'être une variable d'environnement) et les actions fondateur 1 et 2 du plan moteur (jetons GitHub collés, clé AI Gateway budgétée).
Origine : ADR 0006 du dépôt applicatif Orbit, renumérotée 0016 à l’intégration dans cette série (une seule série d’ADR vit désormais ici).
Statut dans Orbit (2026-10) : en vigueur (proposé, étapes C1 et C2 livrées). Décision reprise de l'ancien dépôt Studio ; le paquet est
@orbit/connectors, le registreproducts/connectors.jsond'Orbit (liaisonsstudio.*,"products": {}), le dépôt de renduBoostEcom/orbit.easyconnector.app. Les exemples de liaisons de produit sont écrits avec l'idorbit; Orbit n'a pas de plateforme parente : le portPlatformBridge(capacitéplatform) est supprimé du code (CaptureSecretreste, capacité de produit), et les variables des anciens ponts (PLATFORM_*,CAPTURE_BRIDGE_SECRET,CINEMA_EXCHANGE_ORIGIN,PRODUCT_REPOS_READ_TOKEN) sont retirées du schéma ; les passages qui les citent, ou qui parlent de « la plateforme », sont l'historique de la décision. Les variablesSTUDIO_*renommées (ORBIT_RENDER_DISPATCH_TOKEN,ORBIT_PROVIDERS,ORBIT_PUBLIC_URL) sont citées sous leur nom Orbit.
Amendement du 2026-09-28 (décision du propriétaire) : infrastructure = Marketplace, Connect = connecteurs utilisateur. L'infrastructure d'Orbit elle-même (Neon, Upstash, Resend) passe par les intégrations du Vercel Marketplace, dont les variables sont injectées par l'intégration. Vercel Connect est réservé aux connecteurs par produit que les membres branchent dans l'application (GitHub, analytics, réseaux). En conséquence,
studio.mailerpasse de{ "connect": "resend/studio" }(avec le secoursRESEND_API_KEY) à{ "marketplace": "resend" }, qui litRESEND_API_KEYinjectée par l'intégration Resend ; la liaison ne déclare plus de secours ni de dérogation, et l'action fondateur F1 (créer le connecteur Resend sur Connect) est retirée. Le catalogue acceptemarketplacepour l'adaptateurresend, en premier dans son ordre de préséance (la préséance d'un adaptateur est désormais l'ordre de sesaccepts, plus l'ordre global). Là où la suite de ce texte décrit la messagerie sur Connect et son secours, cet amendement prévaut.
Mise en œuvre. L'étape C1 (paquet
@orbit/connectors, registreproducts/connectors.json) est décrite dans Connecteurs Studio, qui note aussi ses écarts avec ce texte, rédigé avant la fusion du moteur surmain(la vidéo asynchrone y est désormais, d'où une liaisonvideoJobsdès la v1 du registre).Révision 2, finale. Elle applique
CRITIQUE.md(33 constats : 7 hauts, 13 moyens, 13 bas). Chaque constat est appliqué là où il porte, ou rejeté avec sa preuve (section « Constats rejetés », en fin de document). La table « Résolution de la critique » relie chaque constat à sa réponse.Sources : les rapports
A-vercel-capabilities.mdetB-services-inventory.md(dossier de travail du plan, hors dépôt) ; le studio àorigin/mainbdcb518; la branche moteurstudio/04-moteurs(WP1 à WP4 committés jusqu'à07bb07f: AI SDK 7,gatewayAuth(),ORBIT_PUBLIC_URL,VERCEL_OIDC_TOKEN,BLOB_STORE_ID, vidéo asynchrone ; WP5 en cours dans l'arbre de travail : références, store@vercel/blob2.8.0, route de téléversement, hôte Blob de la CSP) ; la branche de transplantationstudio/06-design-plateforme(étape 1 committée,c4f5de6) ; la plateforme à8df697416; les paquets@vercel/connect2.3.3,@vercel/blob2.8.0 et@ai-sdk/gateway4.0.94, lus dans leur code publié ; les vérifications du 2026-09-27 citées au fil du texte.Plans voisins, avec lesquels celui-ci reste cohérent :
engine/ENGINE-PLAN.md(parité des moteurs) etport/PLAN.md(transplantation octet pour octet de l'interface).
Contexte
La demande
Le fondateur, le 2026-09-27 :
- « je dois pas mettre 1 repo mais pouvoir depuis l'app avoir un connector et Vercel gère bien cela aussi » ;
- « Assure-toi également que Postiz, DataFast, etc. soient bien aussi des connectors et ainsi pouvoir si besoin changer sans problème sans tout casser. Vercel gère cela proprement logiquement. »
Deux exigences, donc. Remplacer un fournisseur (Postiz par un autre planificateur, DataFast par un autre outil de mesure) ne doit rien casser. Faire tourner ou révoquer un crédit non plus. Et le crédit ne doit plus être un jeton collé à la main quand Vercel sait mieux faire.
Réponse courte. Oui, Vercel le fait proprement, avec trois mécanismes : Vercel Connect garde les clés et les jetons hors du code et les rend au moment de l'appel (rotation sans redéploiement, attachement par projet et par environnement) ; l'OIDC de Vercel supprime le secret pour les services de Vercel eux-mêmes (AI Gateway, Blob, Sandbox, Flags) ; les intégrations Marketplace injectent les leurs (Upstash, Neon). Le studio ajoute ce que Vercel ne fait pas : un port par capacité et un registre relu en PR, pour que changer de fournisseur soit un adaptateur et une ligne, et que retirer une clé casse au pire un geste, jamais l'application. La promesse a une portée écrite (b, « Portée de la promesse ») : publication, mesure, messagerie, dépôts, KV, pont plateforme et mot de passe de vitrine s'échangent par une ligne ; la voix, l'IA, le stockage et les téléversements sont couplés par conception, et leur crédit, lui, tourne ou se révoque sans rien casser comme partout.
Ce qui existe dans le studio (origin/main)
- Chaque appel lit sa variable là où il appelle.
apps/web/src/generation/deps.tslitAI_GATEWAY_API_KEY,ELEVENLABS_API_KEY,BLOB_READ_WRITE_TOKEN,CREATIVE_RENDER_REPOetORBIT_RENDER_DISPATCH_TOKEN.lib/mail/transport.tslitRESEND_API_KEYetSTUDIO_MAIL_FROM,lib/security/kv.tslitKV_REST_API_*, etrate-limit.ts,lockout.tsetonce.tsappellent le client Upstash quegetRedis()leur rend (rate-limit.tsimporte aussi@upstash/ratelimit). Les scripts depackages/remotionlisentprocess.envdirectement (postiz.mjs,tts.mjs,publish-artifact.mjs,callback.mjs). Il n'y a d'indirection que pour trois interfaces du moteur :StudioProviders,BlobStoreetRenderConfig. - Huit crédits de fournisseurs sont collés à la main :
RESEND_API_KEY,AI_GATEWAY_API_KEY,ELEVENLABS_API_KEY,POSTIZ_API_KEY,ORBIT_RENDER_DISPATCH_TOKEN(PAT),PRODUCT_REPOS_READ_TOKEN(PAT),DATAFAST_API_TOKENetPLATFORM_INTELLIGENCE_API_KEY.BLOB_READ_WRITE_TOKEN, lui, est posé par Vercel à la création du store, mais sa copie dans les secrets GitHub du rendu est collée à la main.DATABASE_URLetKV_REST_API_*sont injectés par les intégrations Marketplace (Neon, Upstash).CINEMA_SESSION_COOKIE(outillage local du cinéma) est une session de la plateforme, donc un crédit. - La clé Postiz atteint quatre groupes, dont les comptes personnels du fondateur (carte §3.1.5).
products.jsonporte unpostiz.integrationspar produit, vide partout et lu par aucun code. Rien n'empêche donc un brouillon d'atterrir sur n'importe quel canal que la clé voit. - Des secrets copiés à la main dans GitHub :
BLOB_READ_WRITE_TOKEN,CREATIVE_RENDER_CALLBACK_SECRETetCREATIVE_RENDER_CALLBACK_URLdans les secrets du rendu (render.yml:133,145,154-155), etPRODUCT_REPOS_READ_TOKENpour la garde croisée (design-drift.yml:31). Les deux premiers vivent aussi dans Vercel : tourner un côté seulement casse les rendus en silence. - Deux lecteurs pour un même service : Postiz (la CLI lit
POSTIZ_API, le schéma déclarePOSTIZ_API_URL) et ElevenLabs (web et CLI). - Sept variables déclarées ne sont lues par rien (B §4.4). Elles se renomment ou se retirent sans coût.
- Un nom de fournisseur est persisté :
ContentReview.postizPostId String @unique(packages/db/prisma/schema.prisma). Le rapport B ne le relevait pas. Aucun code ne le lit encore. - Des préséances silencieuses dans le code en cours. La branche moteur (WP3) choisit la clé ou l'OIDC, et
« la clé l'emporte quand les deux sont posés » (
deps.ts,gatewayAuth()), comme le fournisseur Gateway de l'AI SDK lui-même (getGatewayAuthToken:AI_GATEWAY_API_KEYd'abord, l'OIDC ensuite,@ai-sdk/gateway4.0.94). Le modeautopasse en direct dès queVERCEL_OIDC_TOKENest présent hors production, etblobStore()prend le store distant dès qu'un crédit Blob existe. - Le cockpit transplanté affiche déjà une section « Connecteurs » :
StoreSystemDashboard, vendu tel quel, code en dur Shopify, Meta, Google, Klaviyo, Notion et Figma (store-dashboard.tsx:72-79, rendu:438-475), qui liront tous « Non connecté » dans le studio.
Ce que Vercel fournit (vérifié, ou tiré de A avec sa source)
- Vercel Connect (GA sur tous les plans selon A, Pro facturé 3 $ les 1 000 demandes de jeton depuis
le 2026-09-25). Le code appelle
getTokenResponse(uid, { subject: { type: "app" } })au moment de l'appel (le sujet est un objet :{ type: "app" }, ou{ type: "user", id }pour une personne). Le jeton OIDC du déploiement authentifie cet appel, et Connect le confronte au lien de projet du connecteur (projet et environnements). Un lien créé sans liste d'environnements les couvre tous les trois, Development compris (schéma RESTcreate_connector: « If environments is omitted, the connection uses development, preview, and production ») : on choisit donc les environnements explicitement. Faire tourner, révoquer ou remplacer le crédit ne demande aucun redéploiement, alors qu'une variable, même injectée par le Marketplace, en demande un.- Paquet
@vercel/connect2.3.3 (lu dans le paquet publié). Il exportegetToken,getTokenResponse(token,tokenId,expiresAt,connector { id, uid, type }),deleteTokenCacheEntry,revokeToken,startAuthorization(authentifiée par le jeton OIDC, donc appelable depuis une action serveur),getConnectorMetadataetexperimental_startInstallation(« feature-gated … Contact Vercel »). Il garde un cache en mémoire de 100 entrées et rafraîchit un jeton 30 s avant son expiration (validityBufferMs).forceRefreshcourt-circuite ce cache et fait revalider le droit par Connect.deleteTokenCacheEntryest la réaction documentée à un 401 du fournisseur : elle retire la seule entrée fautive « without paying for a Connect round trip on every call the way forceRefresh does » (token.d.ts). getConnectorMetadataconfirme que le connecteur est attaché au projet et activé pour l'environnement appelant, sans frapper de jeton (connector.d.ts). Selon A, c'est une lecture comptée dans la limite de 200 lectures par minute et par équipe, pas une demande de jeton facturée.- Les erreurs typées sont quatre :
ConnectError(base, aveccode,statusetvendor),NoValidTokenError(no_token),UserAuthorizationRequiredErroretConnectorInstallationRequiredError. Un connecteur absent, non attaché au projet ou non activé pour l'environnement arrive enConnectErrorgénérique, avec sonstatuset soncode. ConnectTokenParams.authorizationDetailsconnaît un type GitHub,{ type: "github_app_installation", org?, permissions?, repositories? }(authorization-details.d.ts), etgetTokenResponseenvoie ces paramètres tels quels dans la requête de jeton (token.js). Un jeton d'installation peut donc probablement être rétréci par l'appelant ; ce n'est pas une frontière, puisque l'appelant choisit (voir f).- Le connecteur générique à clé API (
type: "api-key", schéma REST vérifié) porte desserviceUrls(1 à 8 URL HTTPS), unsubjectTypeapp(une clé pour l'application) ouuser(chaque utilisateur colle la sienne), et desvalues[]avecexpiresAtetscopefacultatifs. La valeur est en écriture seule (writeOnly) : Connect ne la rend jamais. Elle se gère partoAdd,toDeleteettoUpdate, qui remplace une valeur stockée en place (par son id : « Replacement API key value »). - Le connecteur
githubaccepte une app GitHub propre (appId,appSlug,appName,clientId,privateKeyPemetwebhookSecreten écriture seule) : Connect frappe alors les jetons d'installation de courte durée. Le connecteur GitHub géré est fait pour « Automate issue and pull request work: create, update, label » et ne propose qu'un seul déclencheur,pull_request(vercel.com/connect/github). - Un
uids'écrit<service>/<nom>pour un service du catalogue (resend/…,elevenlabs/…,github/…) etapi-key/<nom>pour le connecteur générique. Il peut être fixé à la création ; le renommer « casse les appelants qui utilisent l'ancien » (schémaupdate_connector). L'uidvit donc à un seul endroit du code. - Limites (A) : lectures (
getToken,getTokenResponse,getConnectorMetadata) 200 par minute et par équipe ; écritures (révocation, création, attachement, lancement d'une autorisation) 50 par minute.
- Paquet
- AI Gateway, budgets (page « Budgets », vérifiée). Quatre portées : équipe, projet, clé, utilisateur. Une
requête OIDC d'un déploiement compte dans le budget de son projet et dans celui de l'équipe ; une requête
par clé compte dans le budget de la clé, de l'utilisateur à qui elle est attribuée et de l'équipe :
« An API key's spend is never attributed to a project, no matter which project uses the key ». Un budget est
un plafond souple (la requête qui le franchit se termine) ; au-delà, le Gateway répond 402,
quota_for_entity_exceeded, avec un message qui nomme la portée (« Project budget exceeded. … »). Dans l'AI SDK 7, ce type inconnu arrive enGatewayInternalServerError, avec le message et le statut 402, sans le type (@ai-sdk/gateway4.0.94, branchedefault). Un budget plafonne une dépense ; il ne réserve aucun solde : deux projets d'une même équipe tirent sur le même solde de crédits. - Vercel Blob (
@vercel/blob2.8.0, code lu).resolveBlobAuthessaie dans l'ordre : une charge présignée, untokenexplicite, l'OIDC (leoidcTokenpassé, sinongetVercelOidcToken()enveloppé pour avaler toute erreur) avecstoreIdouBLOB_STORE_ID, puis retombe en silence surBLOB_READ_WRITE_TOKENs'il est posé (chunk-YYMLUMXS.js:62-69,160-205). Avec unoidcTokenexplicite et sans identifiant de store, il lève au lieu de retomber (:193-195). Connecter un store à un projet y poseBLOB_STORE_ID(un identifiant),VERCEL_OIDC_TOKENetBLOB_WEBHOOK_PUBLIC_KEY(une clé publique) ; le jeton RW est posé à la création du store, et la doc le réserve à deux cas : le code hors de Vercel ethandleUpload.issueSignedToken(par OIDC ou jeton RW : chemin, opérations,validUntilde 7 jours au plus, 1 h par défaut, types permis, taille maximale) puispresignUrldonnent des URL présignées GET, HEAD, PUT ou DELETE. Le protocole de téléversement présigné existe (handleUploadPresigned), mais son client estuploadPresigned, pasupload(). Aucune rotation du jeton RW n'est documentée (seules la création et la suppression d'un store le sont). - Sandbox : politique réseau
custom(allowedDomains,injectionRules: des en-têtes par domaine, injectés à la sortie, dont la session ne relit que les noms), modifiable en cours de session ; délai d'une session de 5 min par défaut (A), fixé à la création et prolongeable ; commandes détachées ; instantanés (la VM s'arrête aprèssnapshot(), une nouvelle session repart d'un instantané). Selon A : Pro jusqu'à 8 vCPU et 16 Go, sessions de 24 h, environ 0,06 $ pour un rendu de 5 min. - Workflows (Workflow DevKit, paquet
workflow) : dans Next, la configuration passe parwithWorkflow(nextConfig)(workflow/next) ; le runtime sertPOST /.well-known/workflow/v1/flowet/.well-known/workflow/v1/step, plus/.well-known/workflow/v1/webhook/:tokenpourcreateWebhook, dont le jeton est la seule autorisation.createHookse reprend (resumeHook) depuis une route à nous.sleepne consomme rien. Une étape dure au plus la limite d'une fonction (A), soit 300 s dans le studio (STUDIO_FUNCTION_MAX_DURATION_S, ENGINE-PLAN WP4). - OIDC :
vercel project token [--format=json]frappe un jeton OIDC de développement pour un script, seul ;vercel env pullécritVERCEL_OIDC_TOKENet toutes les variables ciblées sur Development dans.env.local. Les variables sensibles n'existent qu'en Production et Preview : une variable de Development s'écrit donc en clair sur le poste. Un changement de variable n'atteint que les nouveaux déploiements. Deux modes d'émetteur, Team et Global. GitHub Actions a son propre OIDC (permissions: id-token: write,core.getIDToken(audience)), que Vercel accepte comme « trusted source » pour franchir une protection de déploiement (x-vercel-trusted-oidc-idp-token) ; ici, c'est le studio qui le vérifie lui-même. - KMS (bêta) : signer un JWT depuis une fonction avec une clé gérée par Vercel, autorisé par OIDC, clés
publiques à une URL stable (
vercel.com/docs/kms). Flags :@vercel/flags-cores'authentifie par OIDC (vérifié).
Faits fournisseurs (vérifiés le 2026-09-27)
- DataFast. Les documents de découverte sont conformes : DCR, CIMD, PKCE S256, code d'autorisation,
rafraîchissement et révocation, 29 portées. Mais la doc le dit en toutes lettres : « OAuth tokens
cannot authenticate directly to the public REST API », elles ne valent que sur les points MCP.
Une connexion OAuth dure au plus 90 jours, avec des jetons d'accès d'une heure et un budget de
60 requêtes par minute. En REST, un jeton de compte
dft_se restreint à des permissions (analytics:readseule) et à une liste de sites. Une clé de sitedf_lit un site mais peut aussi écrire des objectifs et des paiements. Un 403 dit « Token does not have the required permission … cannot access the requested website ». - Postiz. Clés et jetons OAuth sont à l'échelle d'une organisation Postiz, et une organisation n'a
qu'une clé : « Rotating is the fix if a key leaks, and the only fix », et la révocation, c'est « Rotate the
key » (
docs.postiz.com/general/settings/developers). Tourner la clé révoque donc l'ancienne sur-le-champ : aucun recouvrement n'est possible. L'onglet Developers existe en Postiz Cloud à partir du plan Standard. La plateforme publie avec sa proprePOSTIZ_API_KEY(src/services/growth/postiz.ts:98). L'OAuth n'a ni découverte, ni PKCE, niexpires_in, ni jeton de rafraîchissement ; l'échange de code se fait en corps JSON, et les jetonspos_n'expirent pas. En-têteAuthorization: <clé>sansBearer.GET /is-connectedvérifie une clé sans effet de bord etGET /groupsliste les groupes. Un 403 dit « The API key is valid but doesn't own the resource ». L'OpenAPI annonce 30 requêtes par heure, le guide 90 par heure (100 en cloud) sur la seule création de post. - GitHub.
GET /repos/{r}/tarball/{sha}répond 302 vers un lien qui, pour un dépôt privé, « expire after five minutes » (Contents: read). Un jeton d'installation frappé sansrepositoriesnipermissionsa « access to all repositories that the installation was granted access to » et « all of the permissions that were granted to the app ». GitHub répond 403 à une permission manquante et à une limite secondaire (x-ratelimit-remaining: 0ouretry-after). Le jeton OIDC d'Actions porterepository,repository_owner,workflow_ref,ref,run_id, et se vérifie contrehttps://token.actions.githubusercontent.com/.well-known/jwks. - Resend est au catalogue Connect (OAuth, MCP ou clé API ;
vercel connect create resend), en sujet « app » ou « utilisateur ». Une clé « Sending access » ne peut appeler que l'envoi : une sonde sans envoi ne prouve que la réponse de Connect. - ElevenLabs est au catalogue Connect (clé API, A).
@remotion/vercelexiste depuis la 4.0.426, alors que le studio épingle Remotion 4.0.242.
Corrections apportées aux rapports A et B
- A listait six classes d'erreur du SDK. Le paquet 2.3.3 n'en exporte que quatre (voir plus haut).
Pour « non attaché » et « introuvable », on lit
ConnectError.statusetcode. - DataFast : le jeton obtenu par l'URL MCP ne sert pas l'API REST (c'est documenté). L'adaptateur
REST prend donc un jeton
dft_en lecture seule, dans un connecteur à clé API. C'était le « jumeau » que A gardait en secours. L'OAuth par MCP devient l'alternative. - AI Gateway : la clé budgétée du plan moteur (G2-26, action fondateur 2) ne protège pas mieux que le
budget de projet, puisque la clé tire sur le même solde d'équipe. Le budget de projet, lui, s'applique
à l'OIDC. Décision : OIDC plus budget de projet. Un budget plafonne une dépense ; il ne réserve pas de
solde. Tant que les deux projets partagent l'équipe, le solde reste commun. La plateforme passe par une clé
(
studio-failure.ts:14: «AI_GATEWAY_API_KEYis one platform-wide key ») : aucun budget de projet ne la compte. Seules deux équipes séparent les soldes (l'autre branche de l'action 2 du plan moteur) : c'est une décision du fondateur. - Blob : la variable de l'identifiant de store est
BLOB_STORE_ID, et Vercel la pose. Le client vendugallery-context.tsxappelleupload()de@vercel/blob/clientvers/api/studio/reference-upload. Ce protocole exigehandleUpload, donc le jeton RW, et le fichier est vendu octet pour octet : le studio ne peut pas le passer àuploadPresigned.BLOB_READ_WRITE_TOKENreste donc dans Vercel, lu par cette seule route (liaisonuploads), jusqu'à ce que la plateforme change de client, puispnpm ui:sync. Tout le reste passe par l'OIDC, avec le crédit passé explicitement (le SDK retomberait sinon sur le jeton RW). Le plan moteur place la route à/api/references/upload: c'est le chemin du client vendu qui compte, et la transplantation désactive aujourd'hui les seuls onglets qui l'appellent. - Le rendu sur Sandbox ne passe pas par
@remotion/vercel, qui imposerait de monter Remotion au-delà de la 4.0.426 (licence, React 18, toutpackages/remotion). La microVM exécute le pipeline existant. - B écrivait « tous les
refànull» : surorigin/main, le produit de la plateforme est épinglé (8df697416…) et référence sonDESIGN.json. Les quatre autres produits sont bien ànull. - B ne listait pas
ContentReview.postizPostId, voir plus haut. - A présentait la clé Postiz comme « rotated in place with overlap » : une organisation Postiz n'a qu'une clé, et la tourner révoque l'ancienne (voir plus haut).
Décision
Un port par capacité, un adaptateur par fournisseur, une liaison par portée dans un registre relu en PR. Le crédit est résolu au moment de l'appel par une seule fonction. Elle préfère Vercel Connect, puis l'intégration Marketplace, puis l'OIDC, et ne prend qu'en dernier recours une variable sensible nommée par connecteur. Le runner GitHub, hors de Vercel, prouve qui il est par l'OIDC de GitHub Actions et ne reçoit que des URL présignées : aucun secret à longue durée de vie ne reste dans GitHub.
Trois couches, et chacune a un seul endroit :
| Couche | Ce qu'elle décide | Où | Qui la change |
|---|---|---|---|
| Liaison | quel adaptateur sert quelle capacité, pour quelle portée, avec quel connecteur et quelle configuration non secrète (identifiants compris : store, site, canaux) | products/connectors.json (git) | une PR relue, avec les tests de contrat |
| Crédit | la valeur secrète et qui a le droit de la demander | Vercel : Connect, intégration Marketplace, OIDC (et, pour le runner, l'OIDC de GitHub Actions vérifié par le studio) | le fondateur, dans Vercel ou chez le fournisseur |
| État | prêt, détaché, dernier succès, dernier échec | la base du studio et son journal | le cockpit (/ops/connecteurs) |
a) Les ports : une interface par capacité, jamais par fournisseur
Règles :
- Un port porte le nom d'une capacité (
Publisher,Analytics,Mailer, ...). Aucun type d'un fournisseur ne le traverse : pas desettings.__typePostiz, pas deproviderOptions, pas d'idpos_, pas de catégorie de voix ElevenLabs. Ce qui est propre au fournisseur reste dans son adaptateur. - Le port appartient à celui qui le consomme. Les ports du moteur restent dans
@orbit/engineet gardent leur contrat actuel, que le plan moteur fait évoluer :BlobStore,StudioProviders(IA et voix),RenderRunner, et la face asynchrone de la vidéo. Les ports de l'application vivent dans un nouveau paquet,@orbit/connectors:Publisher,Analytics,Mailer,SourceRepo,KeyValueetRateLimiter,UploadIssuer,PlatformBridge,CaptureSecret. - La configuration d'une liaison est cuite dans l'instance. Canaux autorisés, site, store : un port
n'accepte ni id de produit ni id de site en paramètre. Un produit ne peut donc pas lire ou écrire chez
un autre, par construction. Deux cas l'écrivent autrement, parce que leur capacité sert plusieurs produits :
sourceRepoest une capacité d'atelier dont les méthodes prennent uneRepoRefrésolue parproducts.json(jamais un nom de dépôt libre) et lisent au commit épinglé ou à la tête de la branche du produit, jamais à un commit fourni par l'appelant (trois produits partageaient un même dépôtEcosystem, chacun sur sa branche) ;PlatformBridgen'accepte qu'une liste fermée de chemins. - Les échecs attendus sont des valeurs (
Result), jamais des exceptions. Les ports du moteur gardent leur contrat par exception, déjà traduit pardescribeStudioFailure. - Chaque port a
probe(), qui éprouve le crédit chez le fournisseur, en une requête au plus et sans écriture, et qui dit la profondeur de sa preuve (provideroucredential) et letokenIdConnect utilisé. Seule exception, la messagerie : une clé « Sending access » ne peut appeler que l'envoi, donc sa sonde s'arrête au crédit (credential) et le dit ; le vrai test est un envoi plafonné au fondateur (« Envoyer un e-mail d'essai »). Le geste « Tester » du cockpit appelleprobe(). - Chaque port a un adaptateur de fixtures, qui passe la même suite de contrat que les adaptateurs réels.
Types communs (packages/connectors/src/core.ts) :
export type AppCapability = "publisher" | "analytics" | "mailer" | "sourceRepo" | "kv" | "uploads" | "platform" | "captureSecret"
export type EngineCapability = "ai" | "voice" | "videoJobs" | "render" | "blob"
export type Capability = AppCapability | EngineCapability
/** "studio" : toutes les portées ; "libre" : le workspace sans produit ; sinon un id de products.json. */
export type ScopeId = "studio" | "libre" | (string & {})
/** L'adresse d'une liaison. Un uid Connect n'est qu'un des mécanismes possibles derrière elle. */
export type ConnectorRef = { scope: ScopeId; capability: Capability }
export type FailureReason =
| "unbound" | "disabled" | "not-configured" // côté studio
| "consent-required" | "installation-required" | "no-token" | "not-linked" | "connect-unreachable" // côté Connect
| "rejected" | "forbidden" | "rate-limited" | "provider-error" | "timeout" // côté fournisseur
| "out-of-scope" | "invalid" // refusé avant tout appel
export interface ConnectorFailure {
reason: FailureReason
retryable: boolean
httpStatus?: number
/** Nettoyé, 300 caractères au plus : jamais un secret, un en-tête ni un corps de requête. */
detail?: string
}
export type Result<T> = { ok: true; value: T } | { ok: false; error: ConnectorFailure }
export type ProbeResult =
| { ok: true; checkedAt: string; depth: "provider" | "credential"; account: string | null; tokenId: string | null }
| { ok: false; checkedAt: string; error: ConnectorFailure }
export interface Port<C extends Capability> {
readonly capability: C
/** "postiz", "datafast", "fixture", ... : le nom de l'adaptateur, jamais un secret. */
readonly adapter: string
probe(signal?: AbortSignal): Promise<ProbeResult>
}
rejected est un 401 du fournisseur (la clé ne vaut plus) ; forbidden est un 403 (la clé vaut, mais ses
droits ou son périmètre ne couvrent pas l'opération) ; ils ne se confondent pas (c, « Le garde »).
Publisher (Postiz aujourd'hui)
Dérivé de ce que la plateforme appelle et que la transplantation porte telles quelles :
listPostizIntegrations (et son filtre isVideoNetwork / isTextNetwork dans
listDistributionChannels), uploadPostizMediaFromUrl et uploadMp4 de la CLI, createPostizDraft
(corps construits par buildVideoDraft et buildTextDraft), setPostizPostStatus (schedule ou
draft), listPostizPosts et postizPreviewUrl. Jamais « publier maintenant », jamais supprimer,
jamais modifier : c'est la doctrine de services/growth/postiz.ts.
export type Network = "tiktok" | "youtube" | "x" | "instagram" | "linkedin" | "facebook" | "threads" | "bluesky" | "other"
export type MediaKind = "video" | "image"
export interface Channel {
id: string
network: Network
/** Clé du fournisseur (ex. "tiktok-business"), affichée, jamais interprétée hors de l'adaptateur. */
providerKey: string
name: string
group: string | null
pictureUrl: string | null
disabled: boolean
accepts: readonly MediaKind[]
}
export type MediaSource =
| { kind: "url"; url: string; fileName: string; contentType: string } // URL publique ou présignée (15 min)
| { kind: "bytes"; bytes: Uint8Array; fileName: string; contentType: string }
export interface MediaHandle { id: string; url: string }
export interface DraftRequest {
channelId: string
kind: MediaKind | "text"
text: string
title?: string
media: readonly MediaHandle[]
/** Mention IA quand le réseau la connaît (label TikTok, made_with_ai de X). Toujours vrai pour un rendu du studio. */
madeWithAi: boolean
plannedAt?: Date
}
export type PublicationState = "draft" | "scheduled" | "published" | "error" | "unknown"
export interface Publication {
externalId: string
channelId: string | null
state: PublicationState
publicUrl: string | null
plannedAt: string | null
}
export interface Publisher extends Port<"publisher"> {
listChannels(filter?: { accepts?: MediaKind }): Promise<Result<Channel[]>>
attachMedia(source: MediaSource): Promise<Result<MediaHandle>>
createDraft(draft: DraftRequest): Promise<Result<{ externalId: string | null }>>
setState(externalId: string, state: "draft" | "scheduled"): Promise<Result<{ externalId: string; state: PublicationState }>>
listPublications(window: { from: Date; to: Date }): Promise<Result<Publication[]>>
previewUrl(externalId: string): string | null
}
L'adaptateur Postiz vit dans apps/web/src/connectors/adapters/postiz.ts : il réutilise les
constructeurs de @/features/growth/postiz-draft (fichier vendu octet pour octet par la
transplantation), ce qui garde un seul module de règles par réseau, comme le demande la carte.
setState n'accepte qu'un externalId que le studio a créé pour cette portée. L'appelant le vérifie en
base (ContentReview ou ContentRender) avant d'appeler. Le quota de Postiz est par organisation
(30 requêtes par heure selon l'OpenAPI) : listChannels est gardé 10 minutes par liaison dans le KV, et le
message rate-limited dit que le quota est celui de l'organisation.
Analytics (DataFast aujourd'hui), en lecture seule
Dérivé de getDatafastOverview, getDatafastGoals et getDatafastCampaigns (src/lib/datafast-api.ts
sur la plateforme), et de leurs lecteurs : tools-board.ts (7 jours de visiteurs et d'objectifs) et
launch-cockpit.ts (visiteurs par utm_campaign). Le studio n'écrit aucun événement : les écritures de
la plateforme restent sur la plateforme.
export interface DateRange { from: Date; to: Date }
export interface Analytics extends Port<"analytics"> {
overview(range: DateRange): Promise<Result<{ visitors: number; pageviews: number | null }>>
goals(range: DateRange): Promise<Result<Array<{ goal: string; completions: number }>>>
campaigns(range: DateRange, filter?: { utmCampaign?: string }): Promise<Result<Array<{ campaign: string; source: string | null; visitors: number }>>>
dashboardUrl(): string | null
}
Une enveloppe de réponse inconnue devient provider-error, et detail nomme les clés reçues. C'est la
règle « unrecognised » de launch-cockpit.ts : on ne suppose jamais.
Mailer (Resend aujourd'hui)
Dérivé de sendMail({ to, subject, text }) et de ses appelants : code OTP (auth/otp-flow.ts),
invitation (members/actions.ts), alerte d'équipe (credits/ledger.ts) et, avec la transplantation,
« Votre avis » (POST /api/feedback, qui porte l'adresse du membre en replyTo).
export interface MailMessage { to: string; subject: string; text: string; replyTo?: string }
export interface Mailer extends Port<"mailer"> {
send(message: MailMessage): Promise<Result<{ messageId: string | null }>>
}
sendMail() reste la façade et garde son contrat (MailUnavailableError). Aucun appelant ne change.
L'adresse d'envoi est dans la configuration de la liaison.
SourceRepo (GitHub aujourd'hui)
Dérivé de l'épinglage repo.ref des produits, des dériveurs qui lisent une copie de travail
(derive-design-md.mjs --root) et de la garde croisée design-drift.yml, qui lit la plateforme.
/** Un dépôt nommé par le registre, jamais par l'appelant : le produit (son repo.name et sa branche), ou le studio. */
export type RepoRef = { product: string } | { studio: true }
/** Le commit épinglé du produit (repo.ref), la tête de sa branche, ou (studio seulement) le commit déployé. */
export type RepoAt = "pinned" | "head" | "deployed"
/** 40 caractères hexadécimaux, validés. */
export type CommitSha = string
export interface SourceRepo extends Port<"sourceRepo"> {
resolveHead(ref: RepoRef): Promise<Result<{ sha: CommitSha }>>
readFile(ref: RepoRef, at: RepoAt, path: string, opts?: { maxBytes?: number }): Promise<Result<{ bytes: Uint8Array; sha: CommitSha }>>
listFiles(ref: RepoRef, at: RepoAt, dir: string, opts?: { recursive?: boolean; limit?: number }): Promise<Result<Array<{ path: string; kind: "file" | "dir"; size: number | null }>>>
/** Lien d'archive de courte durée (GitHub : 302, 5 minutes) pour une copie de travail, sans jeton chez le lecteur. */
archiveUrl(ref: RepoRef, at: RepoAt): Promise<Result<{ url: string; expiresAt: string; sha: CommitSha }>>
}
Un produit absent de products.json est refusé en out-of-scope, sans aucun appel ; pinned sur un produit
sans repo.ref et deployed hors du studio sont invalid ; deployed lit VERCEL_GIT_COMMIT_SHA. En
défense en profondeur, l'adaptateur demande à Connect un jeton rétréci au seul dépôt visé et à contents: read
(authorizationDetails de type github_app_installation, expérience 1) ; la frontière reste l'installation de
l'app (f).
KeyValue et RateLimiter (Upstash aujourd'hui)
Dérivés de lockout.ts (incr, expire, get, set avec délai, del), once.ts (SET NX PX, del) et
rate-limit.ts (fenêtre glissante). Le repli en mémoire reste un adaptateur : dégradé, mais sûr, et le statut
le dit.
export interface KeyValue extends Port<"kv"> {
incr(key: string): Promise<number>
expire(key: string, seconds: number): Promise<void>
get(key: string): Promise<string | null>
set(key: string, value: string, opts: { ttlSeconds: number }): Promise<void>
setIfAbsent(key: string, value: string, ttlMs: number): Promise<boolean>
del(...keys: string[]): Promise<void>
}
/** Le contrat actuel de rate-limit.ts : ne lève jamais ; `durable: false` dit que la réponse vient du repli par instance. */
export interface RateLimiter {
limit(key: string, rule: { limit: number; windowMs: number }): Promise<{ allowed: boolean; remaining: number; resetTime: number; durable: boolean }>
}
lockout.ts et once.ts sont réécrits sur KeyValue, rate-limit.ts sur RateLimiter ; l'adaptateur
Upstash possède @upstash/redis et @upstash/ratelimit (fenêtre glissante, préfixe studio:rl),
l'adaptateur mémoire garde la fenêtre fixe d'aujourd'hui, et rate-limit.ts garde sa composition (Upstash,
puis la mémoire avec 30 s d'attente avant de réessayer Upstash). getRedis() disparaît.
UploadIssuer (téléversements du navigateur, Vercel Blob aujourd'hui)
Dérivé du seul appel que le client vendu fait : upload(chemin, fichier, { handleUploadUrl: "/api/studio/reference-upload" }) dans gallery-context.tsx. La route répond au protocole de ce client
(un jeton client borné, puis le rappel de fin signé). Ce que la route décide reste hors de l'adaptateur.
export interface UploadPolicy {
/** Préfixe imposé à tout chemin, dérivé de la portée du membre (celui que le client vendu construit). */
pathPrefix: string
contentTypes: readonly string[]
maxBytes: number
/** Charge utile du jeton : le membre et la portée, jamais un secret. */
tokenPayload: string
}
export interface UploadedBlob { url: string; pathname: string; contentType: string }
export interface UploadIssuer extends Port<"uploads"> {
/** Répond à une requête du client `upload()` : émission du jeton borné, ou rappel de fin vérifié. */
handle(
request: Request,
policyFor: (clientPayload: string | null) => Promise<Result<UploadPolicy>>,
onCompleted: (blob: UploadedBlob, tokenPayload: string | null) => Promise<void>,
): Promise<Response>
}
L'adaptateur vercel-blob-client appelle handleUpload avec le jeton RW que la liaison résout, passé en
token explicite et lu à l'appel, après avoir vérifié que le store que ce jeton désigne est celui que la
liaison déclare (config.storeId), sinon invalid. Son remplaçant naturel est le même fournisseur en
uploadPresigned sur l'OIDC, le jour où le client vendu change.
PlatformBridge (l'ancienne plateforme, premier connecteur « maison » ; optionnel dans Orbit)
/** Les chemins GET de l'API Intelligence que le studio a le droit de lire : une union fermée, étendue par une PR avec son test de contrat. */
export type IntelligencePath = (typeof INTELLIGENCE_PATHS)[number]
export interface PlatformBridge extends Port<"platform"> {
/** B4 : l'API Intelligence, clé bei_ en lecture. Un chemin hors de la liste est `out-of-scope`, sans appel. */
readIntelligence(path: IntelligencePath, query: Record<string, string>): Promise<Result<unknown>>
/** B1 et B2 : absents tant que la plateforme ne les expose pas (chantier de l'autre dépôt, règle 1). */
requestCaptureTicket?(scene: string): Promise<Result<{ ticket: string; expiresAt: string }>>
readAttribution?(unitIds: readonly string[]): Promise<Result<Array<{ unitId: string; signups: number; sends: number }>>>
}
Seul le produit de la plateforme avait ce pont (dans Orbit : aucun, "products": {}). Un autre produit qui aurait un backend brancherait le sien dans la même
capacité. La liste de chemins commence par celui de la sonde ; chaque geste qui lira B4 ajoute le sien, avec
le schéma zod de sa réponse, dans sa propre PR.
CaptureSecret (mot de passe de vitrine d'Horizon+)
export interface CaptureSecret extends Port<"captureSecret"> {
/** Remis à la seule commande de capture (environnement de la commande en Sandbox). Jamais stocké, jamais journalisé. */
reveal(): Promise<Result<Secret>>
}
Ports du moteur : ce qui change, et seulement ça
-
Secretdans le moteur.packages/engine/src/secret.tsdéclare la même interface structurelle ({ reveal(): string }) que@orbit/connectors: le moteur ne dépend pas du paquet des connecteurs, et le typage structurel rend les deux interchangeables. -
BlobStoregarde la forme du WP5 (put,read,remove,ownsUrl,list). Une seule méthode s'ajoute :presign(pathname, { operation: "get" | "put"; contentType?: string; maxBytes?: number; validForSeconds: number }): Promise<{ url: string; expiresAt: string }>. Elle sert au runner de rendu (un seul chemin, un seul type, une taille maximale), à la microVM et à la remise d'un média privé à un planificateur. Le store s'authentifie par OIDC, avec le crédit passé à chaque appel :oidcToken(lu pargetVercelOidcToken()de@vercel/oidc, qui lève quand il n'y en a pas) etstoreId(celui de la liaison). Le SDK lève alors au lieu de retomber surBLOB_READ_WRITE_TOKEN.ownsUrlaccepte l'hôte du store déclaré, et celui deblobLegacypendant un déménagement (b). -
StudioProviders(image, vidéo, vecteur, voix) garde son interface. Sa configuration change :liveProviders({ gatewayConfigured, elevenLabsKey, publicUrl })(forme du WP3) devientliveProviders({ gateway, voice, publicUrl }), oùgatewayvaut{ mode: "oidc" }(ou{ mode: "key"; key }, hors de Vercel seulement, c) et oùvoiceest un adaptateur du portVoice, rendu neutre :/** "unknown" : la lecture a échoué ; le consentement est alors exigé (fermé par défaut, WP2). */ export type VoiceConsentClass = "stock" | "requires-consent" | "unknown" export interface VoiceSettings { stability?: number; similarity?: number; style?: number; speakerBoost?: boolean; speed?: number } export interface Voice { speak(req: { ctx: CallContext; text: string; model: string; voiceId: string; settings: VoiceSettings }): Promise<VoiceResult> consentClass(voiceId: string, options?: { signal?: AbortSignal }): Promise<VoiceConsentClass> voices(): Promise<Array<{ voiceId: string; name: string; consent: VoiceConsentClass }>> // WP8 quota(): Promise<{ used: number; limit: number } | null> // WP8 }L'adaptateur ElevenLabs traduit :
premadeetgenerateddonnentstock, toute autre catégorierequires-consent, une lecture échouéeunknown; il traduit aussiVoiceSettingsen corps de requête (lebody: Record<string, unknown>brut deVoiceRequestdisparaît). Il reçoit une fonction() => Promise<Secret>, jamais une chaîne figée : une clé tournée est prise au prochain appel. Les ids de modèles (eleven_*) restent ceux d'ElevenLabs : la voix est couplée par conception (b, « Portée de la promesse »). -
RenderRunnerremplaceRenderConfiget les fonctions libres derender/dispatch.ts(launchTemplateRender,listRenderRuns,readRunJobs, lereadRundu WP1) :export interface RenderJob { generationId: string // REQUEST_ID templateId: string // ou "cinema" brief: Record<string, unknown> formats: readonly string[] outputs: readonly string[] label: string // slug + id de génération destinationPrefix: string // generationPrefix(productId, "renders", id) captureTicket?: string // template=cinema (pont B1), jamais journalisé } export type RunVerdict = "queued" | "running" | "success" | "failed" | "cancelled" | "quota" // noms de sweep.ts export interface RenderRunner { readonly kind: "github-actions" | "vercel-sandbox" | "fixture" start(job: RenderJob): Promise< | { ok: true; runRef: string; runUrl: string | null; runsUrl: string } | { ok: false; reason: "not_configured" | "forbidden" | "not_found" | "invalid" | "error"; detail: string }> inspect(runRef: string): Promise<{ verdict: RunVerdict; runnerMinutes: number | null; runUrl: string | null } | null> }Le port ne dit plus s'il est « configuré » : un booléen synchrone ne peut pas connaître un état Connect. La disponibilité se calcule dans l'application (c, « Mode d'échec »). Le type de runner qui a lancé un rendu est enregistré sur la ligne (
params.runner, à côté deparams.workflowRunId), etinspectcomme le balayage choisissent l'adaptateur d'après cette valeur, jamais d'après le flag (d). L'adaptateurgithub-actionsgarderender.yml,return_run_detailset la signature du quota ; il demande un jeton Connect à chaque appel, rétréci au dépôt de rendu (aujourd'huiBoostEcom/orbit.easyconnector.app) etactions: write. L'adaptateurvercel-sandboxviendra à l'étape C7 ; il reçoitstartWorkflowpar injection (sandboxRunner({ startWorkflow })), pour que le moteur n'importe jamais l'application. -
La face asynchrone de la vidéo est celle que le WP4 a ajoutée à
StudioProviders(startVideo,videoStatus, committés en5cc0fba). Son crédit est celui de la liaisonai(OIDC). Le secret de webhook par job reste celui que le Gateway génère : il est gardé sur la ligne (providerWebhookSecret) jusqu'au règlement, puis effacé.
b) Le registre des liaisons : products/connectors.json
Un fichier à côté de products.json, exporté par @orbit/products (./connectors.json) et validé par
@orbit/connectors (zod, plus le catalogue des adaptateurs). products.json dit ce qu'est un
produit. connectors.json dit à quoi il est branché.
{
"version": 1,
// Liaisons valables pour toute portée, « libre » comprise.
"studio": {
"mailer": { "adapter": "resend", "credential": { "connect": "resend/studio" },
"breakglass": "CONNECTOR_STUDIO_MAILER", "config": { "from": "Orbit <orbit@…>" } },
"kv": { "adapter": "upstash", "credential": { "marketplace": "upstash" } },
"blob": { "adapter": "vercel-blob", "credential": { "oidc": "blob" }, "config": { "storeId": "<id du store>" } },
// Le client vendu appelle `upload()` : son protocole signe avec le jeton RW que Vercel pose au store.
"uploads": { "adapter": "vercel-blob-client", "credential": { "marketplace": "vercel-blob" }, "config": { "storeId": "<id du store>" } },
"ai": { "adapter": "ai-gateway", "credential": { "oidc": "ai-gateway" } },
"videoJobs": { "adapter": "ai-gateway", "credential": { "oidc": "ai-gateway" } },
"voice": { "adapter": "elevenlabs", "credential": { "connect": "elevenlabs/studio" }, "config": { "defaultVoiceId": "…" } },
"sourceRepo": { "adapter": "github", "credential": { "connect": "github/product-repos" } },
"render": { "adapter": "github-actions", "credential": { "connect": "github/studio-render" },
"config": { "repo": "BoostEcom/orbit.easyconnector.app", "workflow": "render.yml", "ref": "main" },
"alternate": { "adapter": "vercel-sandbox", "credential": { "oidc": "sandbox" } } }
// Pendant un déménagement de store seulement :
// "blobLegacy": { "adapter": "vercel-blob", "credential": { "oidc": "blob" }, "config": { "storeId": "<ancien store>", "readOnly": true } }
},
// Le workspace sans produit : il hérite de « studio », et ne peut jamais recevoir une capacité de produit.
"libre": {},
"products": {
"orbit": {
"publisher": { "adapter": "postiz", "credential": { "connect": "api-key/postiz-orbit" },
"config": { "channels": ["<id d'intégration Postiz>", "…"] } },
"analytics": { "adapter": "datafast", "credential": { "connect": "api-key/datafast-orbit" },
"config": { "site": "<id de site DataFast>" } },
"platform": { "adapter": "orbit-platform", "credential": { "connect": "api-key/orbit-intelligence" },
"config": { "origin": "https://orbit.easyconnector.app" } }
},
"theme": {
"captureSecret": { "adapter": "storefront-password", "credential": { "connect": "api-key/horizon-storefront" } }
}
}
}
Règles :
- Capacités d'atelier (
mailer,kv,uploads,blobetblobLegacy,ai,videoJobs,voice,sourceRepo,render) : déclarées sousstudio. Un produit oulibrene peut en surcharger que les clés de configuration que l'adaptateur déclare surchargeables (exemple :voice.config.defaultVoiceId, la voix d'une marque). Une surcharge ne crée pas une liaison : la liaison reste celle qui déclare (studio:voice), et c'est elle qui porte l'état, la santé et le détachement (c, étape 2). - Capacités de produit (
publisher,analytics,platform,captureSecret) : déclarées sous un produit, jamais sousstudionilibre. Le workspace libre n'a donc ni publication ni mesure, et le geste correspondant y est absent (c'est la règle « vide = aucun brouillon » depostiz.integrations). - Une liaison, c'est un
adapter(connu du catalogue pour cette capacité), uncredential, uneconfigvalidée par l'adaptateur, puis en option unealternateet unbreakglass(Connect seulement, et seulement pourmailer). Laconfigporte des ids, des URL et des listes d'autorisation, jamais un secret. Un test refuse toute valeur qui a l'allure d'un secret : un préfixe connu (sk_,dft_,df_,pos_,ghp_,github_pat_,re_,bei_,vercel_blob_rw_), ou une chaîne à forte entropie dans un champ que le schéma zod de l'adaptateur ne marque pas comme identifiant (un type marquéId) : un id de site DataFast (24 hexadécimaux), un id d'intégration Postiz ou un id de store passent. - Les uid Connect ne sont pas des secrets. Chaque uid s'écrit tel que Vercel l'affiche (ou tel que fixé à
la création) :
<service>/<nom>pour un service du catalogue (resend/studio,elevenlabs/studio,github/studio-render),api-key/<nom>pour le connecteur générique. Il n'apparaît qu'ici. Seul le connecteur de la CLI (elevenlabs/studio-dev, c) vit dans le runbook, parce qu'aucun code ne le lit. - Validation, en deux fichiers pour qu'un paquet ne lise jamais l'application :
packages/connectors/src/registry/registry.test.ts(produits connus de@orbit/products; mécanisme accepté par l'adaptateur ; le mécanisme choisi est le premier que l'adaptateur accepte dans l'ordre de préséance (c), sauf si un champwaiverécrit la raison ; capacités par portée ; aucune valeur d'allure secrète ; le nombre de liaisonsenvne peut que baisser, un cliquet sur le modèle des plafonds de la plateforme) etapps/web/src/connectors/credentials.test.ts(un nomenvoubreakglasssuit^CONNECTOR_[A-Z0-9_]+$, les noms hérités ne passant qu'avecwaiver, et figure dans le schéma d'environnement ; le classement de chaque variable d'allure secrète, f).
Remplacer un fournisseur, par exemple Postiz par un autre planificateur (A cite Zernio, au catalogue Connect en MCP) :
- écrire l'adaptateur (
apps/web/src/connectors/adapters/<fournisseur>.ts) et l'inscrire au catalogue, avec son schéma de configuration ; - créer le connecteur dans Vercel et l'attacher au projet, pour les environnements voulus ;
- changer une ligne de
products/connectors.json; - lancer
pnpm test: les suites de contrat tournent pour chaque adaptateur inscrit.
Rien d'autre ne change. Un test garde cette promesse (no-vendor-import.test.ts) : hors des répertoires
d'adaptateurs, aucun fichier n'importe un module d'adaptateur ni un SDK de fournisseur
(@upstash/redis, @upstash/ratelimit, @vercel/blob, @vercel/connect, ...). Les fichiers vendus du
manifeste de l'interface en sont exemptés, puisqu'ils ne changent pas ici : le seul concerné,
gallery-context.tsx, importe upload de @vercel/blob/client, dont le côté serveur est la liaison uploads.
Aucune phrase de l'interface ne nomme le fournisseur : les deux phrases vendues qui disent « Postiz » sont
surchargées (e), et un test vérifie qu'aucun texte rendu par les gestes atteignables ne nomme l'ancien
adaptateur. Les lignes déjà créées gardent leur fournisseur (ContentReview.publisher,
ContentRender.publisher, métadonnées du journal), et l'unicité d'un id externe est par fournisseur
(@@unique([publisher, externalPostId])) : deux planificateurs peuvent rendre le même id. Le nouvel adaptateur
ne touche pas un externalId étranger : il le refuse avec une phrase (« Brouillon créé chez l'ancien
planificateur : à gérer là-bas »). L'historique se lit, rien ne casse. Revenir en arrière, c'est rétablir la
ligne, ou basculer le flag sur l'alternate quand la liaison en déclare une (d).
Portée de la promesse. Échangeables par un adaptateur et une ligne : publisher, analytics, mailer,
sourceRepo, kv, platform, captureSecret. Couplés par conception, hors de cette promesse : voice (ids de
modèles eleven_*, prix par caractère ELEVENLABS_*_MICROS_PER_CHAR, que le journal, les prix et le compositeur
vendu portent), uploads (le protocole du client Vercel Blob qu'impose gallery-context.tsx), ai et
videoJobs (ids vendor/model : le Gateway est lui-même la couche d'échange entre fournisseurs de modèles),
blob (les URL publiques des rendus sont celles du store). Pour ceux-là, changer de fournisseur est un chantier,
pas une ligne ; le port en réduit le périmètre (la catégorie de voix devient une classe de consentement neutre,
les réglages un type à nous), et le crédit, lui, se tourne ou se révoque sans rien casser comme partout.
c) Résolution du crédit : une seule fonction
/** Une valeur secrète opaque : toString(), toJSON() et util.inspect rendent "[secret]" ; seul reveal() la lit. */
export interface Secret { reveal(): string }
export type CredentialRef =
| { connect: string; subject?: "app" | "founder"; scopes?: readonly string[] } // "app" → { type: "app" } ; "founder" → { type: "user", id }
| { marketplace: "upstash" | "vercel-blob" }
| { oidc: "ai-gateway" | "blob" | "sandbox" | "workflow" | "flags" }
| { env: string; waiver: string }
export type ResolvedCredential =
| { via: "connect"; secret: Secret; expiresAt: number | null; tokenId: string | null; invalidate(): void }
| { via: "marketplace"; values: Readonly<Record<string, Secret>> }
| { via: "oidc" } // l'adaptateur obtient le jeton et le passe explicitement quand le SDK le permet
| { via: "env" | "breakglass"; secret: Secret }
| { via: "fixture" }
export function resolveCredential(ref: ConnectorRef, options?: { forceRefresh?: boolean; signal?: AbortSignal }): Promise<Result<ResolvedCredential>>
forceRefresh ne sert qu'au geste « Tester », qui veut que Connect revalide le droit ; le garde ne s'en sert
jamais.
Ordre de préséance. C'est l'ordre dans lequel on choisit le mécanisme d'un service, contrôlé par le test du registre :
- Vercel Connect partout où il sert le fournisseur (OAuth, clé API, MCP, app GitHub) :
getTokenResponse(uid, { subject }), authentifié par l'OIDC du déploiement. - Variables d'une intégration Marketplace pour ce que le Marketplace fournit et facture (Upstash ;
Neon reste la base de données et n'est pas un connecteur). Même régime pour le jeton RW que Vercel
pose avec un store Blob : injecté, jamais collé, et que la doc réserve au protocole
handleUpload. - Fédération OIDC pour les services Vercel eux-mêmes : AI Gateway, Blob, Sandbox, Workflows, Flags.
- Une variable sensible nommée par connecteur (
CONNECTOR_<PORTÉE>_<CAPACITÉ>), en dernier recours, avec unwaiverécrit.
Le runner de rendu, qui tourne chez GitHub, n'a aucun de ces quatre mécanismes : il présente le jeton OIDC de GitHub Actions au studio, qui le vérifie et lui rend des URL présignées (f, « Runner »).
Algorithme à l'exécution :
- Mode. Un seul prédicat,
isVercelDeployment():VERCEL_ENVvautproductionoupreview(vercel devetvercel env pullposentdevelopment, qui n'en est pas). Il sert àproviderMode,mailTransport,blobStoreetresolveCredential, à la place deNODE_ENV === "production", quenext startsur un poste satisfait aussi. Hors de Vercel, la messagerie reste la console, le KV reste en mémoire et le Blob reste le store local, quel que soitORBIT_PROVIDERS; les autres connecteurs de l'application et le moteur suiventORBIT_PROVIDERS, oùautovaut fixtures (même avec unVERCEL_OIDC_TOKEN) etliveest un choix explicite. En test, fixtures partout, aucun réseau. Sur Vercel, les fixtures sont refusées. - Liaison. Registre, puis
(scope, capability). Pas de liaison :unbound. Pour une capacité d'atelier, la résolution rend d'abord la liaison déclarante (studio:voicepour toute portée qui en hérite), avec les surcharges de configuration de la portée. État, santé, cache négatif et « Détacher » sont indexés par la liaison déclarante. - État studio. Si
StudioConnectorHealth(<liaison déclarante>).disabledAtest posé :disabled. - Mécanisme déclaré :
connect:getTokenResponse. Les erreurs se traduisent ainsi :UserAuthorizationRequiredErrorenconsent-required,ConnectorInstallationRequiredErroreninstallation-required,NoValidTokenErrorenno-token, unConnectError401, 403 ou 404 ennot-linked(lecodeest gardé pour le message). Une erreur réseau, un 429 ou un 5xx devientconnect-unreachable.- Secours (
breakglass, messagerie seulement) : s'il est déclaré et non vide, il sert à chaque fois que la liaison ne peut pas envoyer à cause du crédit ou de Connect (not-linked,no-token,consent-required,installation-required,connect-unreachable, et unrejectedouforbiddendu fournisseur), jamais pour un message refusé sur son contenu ni pourrate-limited(une seconde clé du même compte ne lève pas une limite). Chaque usage est journalisé (connector.breakglass_used) ; tant que la variable est posée,/api/healthet le cockpit affichent « Secours actif ». marketplace: lit les variables de l'intégration paresseusement (au premier usage, jamais au chargement du module), puis les garde pour la vie de l'instance : une valeur de variable ne change qu'avec un redéploiement. Absentes :not-configured. Pourvercel-blob, le store que désigne le jeton RW doit être celui deconfig.storeId, sinoninvalid.oidc: vérifie qu'un jeton OIDC est obtenable (getVercelOidcTokende@vercel/oidc, qui lève). Sinon :not-configured. Règle des préséances silencieuses : quand un SDK préfère une variable au mécanisme déclaré, l'adaptateur passe le crédit explicitement (Blob :oidcTokenetstoreIdà chaque appel) ; quand il ne le peut pas, la résolution refuse la variable sur Vercel. C'est le cas du Gateway, qui prend toujoursAI_GATEWAY_API_KEYavant l'OIDC (getGatewayAuthToken) : sur un déploiement Vercel, une liaisonoidc: "ai-gateway"exige queAI_GATEWAY_API_KEYsoit absente. Sinon la résolution rendinvalid(« Une clé AI Gateway est posée dans Vercel : elle contourne le budget du projet. La supprimer, puis redéployer. »), les capacitésaietvideoJobssont refusées et/api/healthle signale.env: lit la variable nommée. Vide :not-configured.
- Cache négatif. Tout échec autre que
connect-unreachableest gardé 30 s par liaison déclarante. Cela protège la limite de lecture de Connect (200 requêtes par minute et par équipe, selon A) et les quotas des fournisseurs.
Le garde autour de chaque port (guard(port, ref), dans @orbit/connectors) fait, pour chaque
appel :
- il vérifie les listes d'autorisation avant tout appel (canal, dépôt, chemin), en
out-of-scope; - il pose un délai par capacité, aligné sur la table
PROVIDER_BUDGET_MSdu WP3 ; - sur un 401 seulement, avec un crédit
connect, il appelleinvalidate()(deleteTokenCacheEntryavec les mêmes paramètres), résout à nouveau (un aller-retour Connect) et rejoue une fois. Au second 401 :rejected. C'est ce qui rend une rotation invisible. Un 403 devientforbidden(retryable: false), sans rejeu : c'est un droit ou un périmètre, pas une clé révoquée (Postiz : ressource d'une autre organisation ; DataFast : permission ou site ; Resend : domaine non vérifié). Exception : un 403 de GitHub avecx-ratelimit-remaining: 0ouretry-afterestrate-limited; - il enregistre le succès ou l'échec dans
StudioConnectorHealth, au plus une écriture par minute et par liaison pour les succès ; - il nettoie
detail: pas d'en-tête, pas de corps, pas de chargeConnectError.vendor.
Développement local et tests :
- Tests (
pnpm test) : fixtures partout.resolve.test.tscouvre la traduction de chaque erreur, avecvi.mock("@vercel/connect"). Aucune suite ne touche le réseau. - Tests de contrat des adaptateurs réels : un
fetchrejoué depuis des réponses enregistrées, synthétisées à partir de la documentation des fournisseurs, avec des ids factices et aucun secret. pnpm dev: fixtures, console, KV en mémoire,.blob/et le Postgres local (studio_gate), comme aujourd'hui. L'environnement Development de Vercel ne reçoit aucun crédit : ni store Blob, ni Neon, ni Upstash, ni connecteur à effet de bord. On ne lance jamaisvercel env pulldansapps/web: il écrirait en clair toute variable ciblée sur Development dans.env.local, que Next lit avant.env.- Pour un jeton OIDC local sans rien tirer d'autre :
export VERCEL_OIDC_TOKEN="$(vercel project token --format=json | jq -r .token)", puisORBIT_PROVIDERS=live pnpm dev. Seuls répondent les connecteurs attachés à Development, tous en lecture seule (github/product-reposen option) ; l'IA dépense alors sur le budget du projet ; messagerie, KV et Blob restent locaux par la règle 1. Une clé AI Gateway personnelle et budgétée reste possible sur un poste (LOCAL_ONLY), jamais sur Vercel. - Les outils en ligne de commande de
packages/remotion(tts.mjs) lisent toujours leur variable, mais la valeur vient de Connect au moment de l'appel, sans fichier sur disque, depuis un second connecteur réservé au développement :ELEVENLABS_API_KEY="$(vercel connect token elevenlabs/studio-dev --subject app)" pnpm --dir packages/remotion tts ….elevenlabs/studio-devporte sa propre clé restreinte (et un plafond de caractères si la console en offre un), attachée à Development seulement ;elevenlabs/studio, celui de la production, n'est jamais attaché à Development. Pour un connecteur à clé API, le « jeton » est la clé elle-même : quiconque lance cette commande la tient le temps de la commande. Si--subject appest refusé depuis la CLI d'un membre (expérience 3), repli : une clé personnelle dans le shell, classéeLOCAL_ONLY.
Cache des jetons : seul le cache en mémoire du SDK (100 entrées par instance, rafraîchi 30 s avant
l'expiration) garde un jeton. Le studio n'écrit jamais un secret en base, dans KV ou Blob, dans l'entrée
ou la sortie d'une étape de Workflow, ni dans le journal. Une étape de Workflow demande son jeton au
moment où elle en a besoin. Une exception : le secret de signature par job que le Gateway génère (WP4) est
gardé sur la ligne (StudioGeneration.providerWebhookSecret) jusqu'au règlement, puis effacé.
Rotation, par mécanisme :
| Mécanisme | Tourner | Redéploiement |
|---|---|---|
| Connect, clé API, fournisseur à clés multiples (Resend, ElevenLabs, DataFast) | 1. créer la nouvelle clé chez le fournisseur (l'ancienne reste valide) ; 2. Vercel, onglet Connect, connecteur : remplacer la valeur en place (toUpdate de la valeur stockée ; Connect n'en garde qu'une, la question « laquelle des deux getToken rend » ne se pose plus) ; 3. « Tester » dans le cockpit, qui ne peut plus éprouver que la nouvelle ; 4. révoquer l'ancienne chez le fournisseur : les instances chaudes reçoivent 401, invalident et relisent. Si le test échoue, ne rien révoquer : l'ancienne sert encore les instances chaudes ; créer une clé corrigée, la poser en place, retester. On ne « remet » jamais l'ancienne valeur : Connect ne la rend pas (value en écriture seule) et le fournisseur ne la montre qu'une fois | non |
| Connect, clé API, clé unique par organisation (Postiz) | tourner la clé chez Postiz (l'ancienne meurt aussitôt), puis toUpdate de la valeur dans Connect dans la minute. Les instances chaudes reçoivent 401, invalident et relisent. Indisponibilité : le temps de coller la valeur. Jamais l'organisation qu'utilise la POSTIZ_API_KEY de la plateforme : tourner d'un côté révoquerait l'autre | non |
| Connect, OAuth | Connect rafraîchit seul. Le secret client tourne dans le connecteur (clientSecret, en écriture seule) | non |
| Connect, app GitHub propre | GitHub accepte plusieurs clés privées par app : générer la nouvelle, la poser dans le connecteur (privateKeyPem), « Tester », supprimer l'ancienne chez GitHub | non |
| Marketplace (Upstash, Neon) | rotation par l'intégration | oui : « old secrets may still be used … until a manual redeployment » |
| Jeton RW du store Blob | rotation non documentée : une fuite se traite en créant un store et en migrant (liaisons blob et blobLegacy, b) | oui |
| OIDC (Vercel, ou GitHub Actions pour le runner) | rien à tourner | non |
| Variable sensible (le secours de la messagerie) | nouvelle valeur | oui |
Révocation, de la plus rapide à la plus forte :
- Détacher dans le studio (cockpit) : la liaison passe
disabled, le geste disparaît à la requête suivante, et c'est journalisé. - Détacher le projet dans Vercel (Connect, connecteur, Projets) : le projet ne peut plus obtenir de jeton. Une instance chaude peut servir un jeton déjà en cache jusqu'à son expiration, d'où le point 1 qui coupe tout de suite.
- Révoquer chez le fournisseur (clé Postiz tournée, jeton DataFast révoqué, app GitHub
désinstallée) : l'appel suivant reçoit 401, puis
rejected. revokeTokendu SDK pour un jeton OAuth : Connect appelle le point de révocation du fournisseur quand celui-ci en déclare un (DataFast oui, Postiz non).
Mode d'échec : un connecteur qui manque rend le geste absent, jamais grisé. La disponibilité d'une
liaison, qui décide des gestes du cockpit (src/cockpit/gestures.ts de la transplantation), se calcule sans
frapper de jeton : liaison déclarée, non détachée, dernier état observé sans échec, et, pour un crédit
connect, un getConnectorMetadata (lien de projet et environnement confirmés, aucun jeton frappé) gardé
5 minutes dans le KV ; pour oidc, marketplace et env, des vérifications locales, sans réseau. Un rendu
dépend de sa liaison render, et, quand l'alternate est choisie, aussi de sourceRepo (l'archive du code) et
de blob (les URL présignées). Si le premier usage échoue malgré tout, le refus suit la forme d'échec de la
plateforme, avec une phrase du catalogue (messages/*.json), l'état est enregistré, et le geste disparaît au chargement suivant. Le
message d'opérateur ne s'affiche qu'à qui tient connectors.manage : il dit quoi faire et où.
Gestes que les connecteurs rallument. La transplantation lit aujourd'hui des variables pour trois gestes
(PORT §3.4) : composition et fabricate exigent rendersConfigured (dérivé du jeton de RenderConfig),
voiceover exige ELEVENLABS_API_KEY et ELEVENLABS_VOICE_ID. À partir de C3 : composition et fabricate
exigent une liaison render disponible, voiceover une liaison voice disponible et une voix par défaut
configurée. La transplantation garde distribution et measure éteints faute de service. Cette ADR amende ce
tableau pour distribution seulement : à partir de C5, il vaut distribute, une portée de produit et un
publisher disponible, et les deux rangs qu'il ouvre (le brouillon de la galerie, le bureau Média) sont alors
servis. measure reste éteint, parce qu'il ouvre aussi le rang Mesure, que sert le pont B2 de la plateforme.
Les chiffres de DataFast se lisent dans un bloc « Mesure, 7 jours » du board de production (C6).
| Raison | Message au fondateur | Où corriger |
|---|---|---|
unbound | Aucun connecteur n'est lié à cette capacité pour ce produit. | products/connectors.json, par PR |
disabled | Détaché dans le studio le {date} par {email}. | /ops/connecteurs?liaison={liaison}, « Rattacher » |
not-linked | Le connecteur {uid} n'est pas attaché à ce projet pour l'environnement {env}. | Vercel, onglet Connect, {uid}, Projets |
consent-required | Autorisation requise. | cockpit, « Autoriser » |
installation-required | Installation requise chez le fournisseur. | cockpit, « Installer » quand Vercel l'a ouvert à l'équipe ; sinon Vercel, onglet Connect, {uid} |
no-token | Accès révoqué ou expiré. | Vercel, onglet Connect, {uid} |
rejected | Le fournisseur refuse la clé : elle a été révoquée ou remplacée. | fournisseur, puis Vercel, onglet Connect, {uid}, valeur |
forbidden | Le fournisseur refuse l'opération : droits de la clé insuffisants, ou ressource hors de son périmètre. | fournisseur (droits de la clé, domaine, site ou dépôts), puis Vercel, onglet Connect, {uid} |
not-configured | {variable ou intégration} n'est pas posée. | Vercel, réglages du projet |
invalid (clé Gateway) | Une clé AI Gateway est posée dans Vercel : elle contourne le budget du projet. La supprimer, puis redéployer. | Vercel, réglages du projet, variables (les trois environnements) |
invalid (store) | Le jeton de téléversement ne correspond pas au store {storeId} du registre. | products/connectors.json, ou le store relié au projet |
connect-unreachable | Vercel Connect ne répond pas, nouvel essai dans 30 s. | rien (la messagerie a son secours) |
rate-limited | Quota du fournisseur atteint, réessayer après {heure}. Pour Postiz, le quota est celui de l'organisation. | attendre |
Un plafond de budget atteint n'est pas une raison de connecteur : c'est un échec du moteur, classé balance
(C3), remboursé et sans nouvel essai. Le membre lit « Le plafond de dépense du studio est atteint. » ; le
fondateur lit en plus où le relever (Vercel, AI Gateway, Budgets), jamais le membre (règle de nommage reprise de
la plateforme, failure.ts).
Journal (StudioAuditLog, métadonnées sans secret) : connector.tested, connector.disabled,
connector.enabled, connector.consent_started, connector.consent_completed,
connector.breakglass_used (acteur système, au plus une ligne par heure) et connector.rejected (au plus
une ligne par heure et par liaison). Côté publication, en noms neutres : publication.drafted,
publication.scheduled et publication.unscheduled, avec { publisher, externalId, channelId, network }.
Les lignes importées de la plateforme (growth.media.postiz_draft) restent lisibles telles quelles.
Coût et limites : chaque demande de jeton qui n'est pas servie par le cache est facturée (3 $ les 1 000 en
Pro, selon A). Le statut affiché ne coûte aucun appel au fournisseur (base et journal), la disponibilité
coûte au plus un getConnectorMetadata par liaison toutes les 5 minutes, et « Tester » se limite à une fois
par 5 minutes et par liaison. La sonde Postiz consomme sur le quota de l'organisation (30 requêtes par heure
selon l'OpenAPI), comme listChannels, gardé 10 minutes.
d) Les capacités Vercel, chacune à sa place
| Capacité | Rôle dans le studio | Ce qu'elle remplace | Étape |
|---|---|---|---|
| Connect | crédits de Resend, ElevenLabs (production, et un connecteur de développement pour la CLI), GitHub (deux apps propres : rendu, lecture), Postiz, DataFast, de l'API Intelligence et du mot de passe de vitrine | RESEND_API_KEY, ELEVENLABS_API_KEY, ORBIT_RENDER_DISPATCH_TOKEN, PRODUCT_REPOS_READ_TOKEN (dans l'app), POSTIZ_API_KEY, DATAFAST_API_TOKEN, PLATFORM_INTELLIGENCE_API_KEY | C2, C3, C5, C6 |
| OIDC + budget de projet AI Gateway | images, vidéos, vecteurs, jobs vidéo, plafonnés par le budget du projet (plafond souple ; aucun solde réservé) | AI_GATEWAY_API_KEY dans Vercel, et la clé budgétée du plan moteur (G2-26) | C3 |
Blob par OIDC (storeId du registre, crédit passé explicitement) et URL présignées | tout fichier que le serveur écrit, lit ou supprime ; sorties du runner (C3b) et de la microVM (C7) ; média remis à un planificateur | l'usage de BLOB_READ_WRITE_TOKEN par le moteur (C3), puis sa copie GitHub (C3b). Le jeton reste pour la seule route handleUpload du client vendu (liaison uploads) | C3, C3b |
| OIDC de GitHub Actions (hors de Vercel, vérifié par le studio) | le runner prouve quel run il est ; le studio lui rend des URL présignées pour les sorties de sa ligne, et un lien d'archive de 5 minutes pour la garde croisée | CREATIVE_RENDER_CALLBACK_SECRET, la copie GitHub du jeton RW, PRODUCT_REPOS_READ_TOKEN (secret GitHub) | C3b |
| Sandbox | exécuter packages/remotion (Remotion 4.0.242 inchangé, Chromium, ffmpeg) dans une microVM : commande détachée que le Workflow sonde, délai explicite, instantané clé par le lockfile de Remotion, deux politiques réseau (préparation, rendu) | render.yml, le dispatch GitHub, le couplage au quota Actions partagé avec la plateforme, la branche « quota » du balayage | C7 (décision du fondateur) |
| Workflows | orchestrer un rendu (préparer ou reprendre l'instantané, rendre, sonder, téléverser, régler) et un job vidéo (startVideo avec la route du WP4 comme webhookUrl, qui vérifie la signature puis reprend un createHook ; attente du hook contre un sleep ; videoStatus ; settleVideoJob inchangé). Exige withWorkflow et deux chemins machine publics exacts | le sondage du balayage (le cron garde les réserves périmées et une réconciliation quotidienne) | C7, C8 |
| Queues | pas d'usage direct : les Workflows reposent dessus. Plus tard peut-être, l'éventail d'un lot de la Fabrique | rien | aucune |
| Flags | choisir entre l'adapter et l'alternate qu'une liaison déclare, pour les nouveaux jobs seulement (un rendu en cours est inspecté par le type enregistré sur sa ligne) ; une erreur de lecture du flag choisit l'adapter | des variables STUDIO_*_BACKEND qu'on aurait ajoutées | C7 |
| KMS (bêta) | option pour les ponts B1 à B3 : le studio signe un JWT avec une clé gérée par Vercel, la plateforme le vérifie contre le JWKS publié ; à côté de l'autre option, vérifier directement le jeton OIDC du studio. Décision de la plateforme | un secret partagé (CAPTURE_BRIDGE_SECRET, PLATFORM_READ_TOKEN) | note en C6 |
| Image Optimization | aucun usage : la transplantation n'a pas de remotePatterns (PORT §6.5, gardé par un test) ; les vignettes sont servies telles quelles par l'hôte Blob | rien | aucune |
| CDN et étiquettes de cache | fichiers de rendu immuables (max-age long). Aucune route du studio n'en a besoin aujourd'hui | rien | aucune |
| Marketplace | Neon (base) et Upstash (KV), injectés par leur intégration, attachés à Production et Preview seulement | rien : c'est déjà le bon chemin | C2 (déclaration) |
| Déclencheurs Connect | aucun usage : le connecteur GitHub géré ne propose que pull_request, et la garde croisée reste hebdomadaire | rien | aucune |
| Sources publiques sans crédit (pas des connecteurs) | cdn.shopify.com et les references.shopDomains de products.json (WP5, fermé par défaut, hôtes exacts) ; les hôtes de préparation de la microVM (registre npm, source de ffmpeg, téléchargement de Chrome Headless Shell, incompetech pour la musique du WP9) ; pour le cinéma, l'origine de la plateforme et CINEMA_EXCHANGE_ORIGIN. Un déménagement est une ligne de registre ou de politique réseau, sans port : il n'y a ni crédit à tourner ni fournisseur à échanger | rien | C7 (politiques réseau) |
Règle pour les flags : un flag ne choisit que parmi ce que le registre déclare, ou éteint. Il n'introduit jamais un adaptateur ni un connecteur que le registre ne nomme pas. Le choix du fournisseur reste relu en PR.
e) La surface fondateur, dans la grammaire de la transplantation
Aucun composant neuf. La transplantation (PORT §4) a fixé la grammaire des surfaces propres au studio : un board est une lecture, la page porte le geste, et le board nomme cette page en pied. Les connecteurs la suivent :
- Lire, dans le cockpit. Le board de production de chaque portée (
loadStudioProduction, chargeur du studio, rendu par leBoardContentvendu) gagne un bloc « Connecteurs ». Une ligne par capacité liée : titre (« Publication », « Mesure », « Dépôt de code », « Voix »), détail (Postiz · api-key/postiz-orbit · 3 canaux), valeur (« Prêt », « À attacher dans Vercel », « Autorisation requise », « Détaché », « Secours actif »), tongoodouwarning, ethrefvers la page de la liaison pour qui tientconnectors.manage. La Plateforme gagne une carte lien « Connecteurs » vers/ops/connecteurs, dansSTUDIO_OPS_ROUTES, comme la carte Membres. Ce n'est pas un board : lesOpsSectionIddu module sont une union fermée, dans un fichier vendu. - Une seule section « Connecteurs » à l'écran. Le
StoreSystemDashboardvendu, que la Plateforme montre pour toute portée productible (PORT §4.1), rend sa propre section « Connecteurs » (Shopify, Meta, Google, Klaviyo, Notion, Figma, tous « Non connecté » ici), qui ne nomme aucun service du studio. Une règle de réécriture déclarée retire cette section, de{/* Connectors */}à sa</Section>(les puces « plugins » qu'elle contient partent avec elle : la base du studio n'a pas de couche plugin, PORT §4.1). Le correctif durable est un chantier de la plateforme (règle 1 du studio) : faire du catalogue un champ de l'hôte, pour que le studio passe ses liaisons dans la grammaire vendue (« Connecter », « Reconnecter », « Gérer »), puispnpm ui:syncet retirer la règle. - Agir, sur une page composée du seul kit admin vendu, comme
/ops/membres:/ops/connecteurs:AdminPage, puisAdminPageHeader(« Connecteurs »), puisAdminSection« Liaisons » contenant unAdminDataTableavec les colonnes capacité, portée (le produit ou « Tout le studio »), fournisseur, connecteur (uid, police mono), mécanisme (Connect clé ou OAuth, intégration, OIDC, variable, secours), statut (AdminStatusBadge), dernier appel réussi et dernier échec ;- le détail est un paramètre,
/ops/connecteurs?liaison=<portée>.<capacité>(exemple?liaison=studio.voice), comme/ops/membres?membre=<id>: lepageTitlevendu (formatSegmentdu dernier segment) titre alors la page « Connecteurs », jamaisOrbit:publisher. Le détail montreAdminDetailHeader,AdminSection« État » (uneAdminStatGrid: statut, dernier succès, dernier échec, dernier test et sa profondeur), puis « Tester » (unAdminFormShellqui appelleprobe()), « Envoyer un e-mail d'essai » pour la messagerie, « Autoriser » (sujetfounderet statutconsent-required:startAuthorizationaveccallbackUrlvers cette page), « Installer » (statutinstallation-required, si Vercel a ouvertexperimental_startInstallationà l'équipe ; c'est le geste le plus proche de « depuis l'app avoir un connector »), « Détacher » ou « Rattacher » (ConfirmActionDialogen ton destructif), et enfin, en texte, où la gérer dans Vercel (uid, environnements attachés).
- Qui voit quoi : la nouvelle permission
connectors.manage(le fondateur) ouvre la page et les messages d'opérateur. Les autres rôles ne voient que la présence ou l'absence des gestes, et le bloc du board sans lien ni conseil. - Les phrases vendues ne nomment pas le fournisseur. Deux valeurs du catalogue
studiosont surchargées par le mécanisme de la tranche (PORT l'emploie déjà 13 fois) :studio.detail.draft« Brouillon Postiz » devient « Brouillon », etstudio.detail.draftTitledevient « Créer un brouillon chez le planificateur. Rien n'est publié : un humain relit et programme. » (la plateforme dit déjà « le planificateur » dansstudio.fabrique.shipHint). Les littérauxreasondes cales (postiz_not_configured) restent : ce sont des codes, pas du texte. - Pourquoi pas
connect-content.tsx. La section « Développeurs » (MCP, clés API) est éteinte par la transplantation (DISABLED_ROWS), parce que le studio n'a ni serveur MCP ni clés.ApiKeysContentest câblé sur les clésbei_: il poste sur/api/intelligence/api-keysavec unorgSlug, affiche le préfixebei_, propose « Créer une clé » et liste les modèles deMODEL_PRICING. Le nourrir de connecteurs ferait dire à l'écran des choses fausses, ou demanderait de surcharger une quarantaine de phrases et de simuler des routes. Une section de rail neuve imposerait d'éditer des fichiers vendus (types.ts,data.tsx). Si le fondateur veut malgré tout une ligne de rail, le chemin propre part de la plateforme : une section générique « Connecteurs » dans le module, utile aussi à la plateforme, puispnpm ui:sync. C'est un chantier de l'autre dépôt, pas un préalable. - Ce qui reste dans Vercel, ou chez le fournisseur : créer un connecteur et y coller une clé, consentir à une installation OAuth, attacher ou détacher un projet en choisissant ses environnements (un lien créé sans liste les couvre tous), faire tourner une valeur, révoquer, supprimer un connecteur, lire l'observabilité de Connect (3 jours de rétention en Pro, selon A), créer et installer les apps GitHub et choisir leurs dépôts, gérer les intégrations Marketplace et leur rotation, relier le store Blob, fixer le budget de projet AI Gateway, poser une variable sensible, gérer les flags, gérer les rôles d'équipe. L'app n'a aucun jeton de gestion Vercel : un geste qui en demanderait un reste dans le tableau de bord.
f) Sécurité
-
Isolation par produit, par construction : l'instance d'un port porte sa liste d'autorisation ; un port n'accepte ni id de produit, ni id de site, ni canal hors liste ;
libren'a aucune capacité de produit ;sourceRepone lit que le dépôt et la branche queproducts.jsondonne au produit ; le pont plateforme ne lit que ses chemins déclarés. Isolation par le fournisseur, quand il la permet : un jeton DataFast limité à un site ; une organisation Postiz qui ne contient que les canaux de la marque (la frontière de Postiz est l'organisation, pas le groupe) ; deux apps GitHub installées sur « Only select repositories ». Les tests de contrat prouvent qu'une liaison du produit A ne liste ni n'écrit chez le produit B (réponse du fournisseur plus large que la liste : filtrée ; canal hors liste : refusé sans un seul appel HTTP). -
Moindre privilège :
Liaison Connecteur (uid attendu) Droits minimaux Environnements attachés orbit.publisherapi-key/postiz-orbit(sujet app)clé d'une organisation Postiz que la plateforme n'utilise pas, sans les comptes personnels, plus la liste de canaux du registre Production orbit.analyticsapi-key/datafast-orbit(sujet app)jeton dft_:analytics:readseule, un seul siteProduction, Preview orbit.platformapi-key/orbit-intelligence(sujet app)clé bei_en lectureProduction, Preview theme.captureSecretapi-key/horizon-storefront(sujet app)mot de passe de vitrine du dev store Production studio.mailerresend/studio(méthode clé API, sujet app)clé Resend « Sending access » limitée au domaine d'envoi Production, Preview studio.voiceelevenlabs/studioclé ElevenLabs restreinte si la console le permet (synthèse, voix et abonnement en lecture) Production, Preview (CLI tts.mjs)elevenlabs/studio-devsa propre clé restreinte, plafond de caractères si offert Development studio.rendergithub/studio-render: app propre « Orbit Studio Render »Actions: read and write, Metadata: read ; installée sur BoostEcom/orbit.easyconnector.appseul ; sans webhook ; jusqu'à C7Production studio.sourceRepogithub/product-repos: app propre « Orbit Studio Read »Contents: read, Metadata: read ; installée sur les dépôts des produits (à l'époque : le studio, la plateforme, Ecosystemet le produit distincteasyconnector.app)Production, Preview (Development en option : lecture seule) studio.ai,studio.videoJobsOIDC le projet studio, sous son budget de projet Production, Preview ; un poste par vercel project token, sur choix explicitestudio.blobOIDC et storeIddu registrele store du studio ; URL présignées pour un chemin, un type et une taille, valables 15 min Production, Preview studio.uploadsjeton RW du store, posé par Vercel lu par la seule route handleUpload; le jeton client qu'elle émet est borné à un préfixe de portée, aux types permis et à une tailleProduction, Preview studio.kvintégration Upstash la base Upstash du studio Production, Preview runner ( render.yml,design-drift.yml)OIDC de GitHub Actions, vérifié par le studio URL présignées pour les sorties déclarées de sa ligne ; lien d'archive de 5 minutes chez GitHub, branche mainUne app GitHub installée donne les mêmes permissions sur chaque dépôt choisi, et Connect frappe un jeton qui les porte toutes tant que l'appelant ne le rétrécit pas. Une seule app avec Actions: write sur les quatre dépôts donnerait donc à une Preview de PR (qui tourne avec le jeton OIDC de Preview) ou à un poste lié le moyen de lancer, relancer, annuler ou supprimer des runs sur le dépôt de l'ancienne plateforme, dont
evals.ymlappelle un modèle payant. D'où deux apps, et l'app de rendu attachée à Production seulement. -
Rien de journalisé :
Secretrend[secret]partout où on l'imprime ou le sérialise ;detailest nettoyé ; la chargeConnectError.vendorn'est jamais écrite. Un test capture la console pendant chaque suite de contrat et vérifie que le secret de la fixture n'y apparaît jamais. Un autre refuse une métadonnée de journal nomméetoken,authorization,apiKeyousecret. -
Les ids ne sont pas secrets, les jetons le sont. Un uid, un id de site, un id de canal ou un id de store vit dans git. Un jeton vit dans Connect, dans l'OIDC, ou en dernier recours dans une variable sensible de Vercel. Jamais dans git, dans la base (sauf le secret de webhook par job du WP4, effacé au règlement), dans le journal, dans un paquet client (le module de résolution importe
server-only), dans une entrée de Workflow, ni dans la VM en dehors de la seule commande qui en a besoin. -
Environnements : la liste d'attachement ci-dessus est la politique. La publication et le rendu ne sont attachés qu'à Production, pour qu'une Preview de PR ne pose aucun brouillon sur les vrais canaux et ne lance aucun run. Development ne reçoit que des connecteurs en lecture seule, et aucun store, base ou KV. Risque résiduel assumé : une Preview partage le store Blob, la base et le KV de production (pour que les Previews puissent générer) ; la protection de déploiement des Previews et la relecture des PR le bornent.
-
Sandbox : deux politiques réseau
custom. Celle de préparation, le temps de construire l'instantané : registre npm, source de ffmpeg (miroir apt ou binaire statique), téléchargement de Chrome Headless Shell, incompetech (musique du WP9),codeload.github.com(archives). Celle de rendu :codeload.github.compour l'archive du code au commit déployé, l'hôte des URL présignées pour les sorties,<store>.public.blob.vercel-storage.compour les entrées, et, pourtemplate=cinema, l'origine de la plateforme etCINEMA_EXCHANGE_ORIGIN. La VM ne reçoit que le ticket de capture à usage unique (cinéma) ou le mot de passe de vitrine (thème), dans l'environnement de la seule commande qui le lit. Aucun autre crédit. -
Runner (C3b) : le studio vérifie le jeton OIDC de GitHub Actions contre
https://token.actions.githubusercontent.com/.well-known/jwks: émetteur, audience (l'origine publique du studio),repositoryBoostEcom/orbit.easyconnector.app,workflow_refexact (render.ymloudesign-drift.ymlàrefs/heads/main),run_idégal à celui de la ligne, ou revendiqué par la première demande valide quand la ligne n'en a pas encore (comparer puis poser). Il ne rend que des URL présignées pour les sorties déclarées de cette ligne, sous son préfixe, ou un lien d'archive de 5 minutes. Un run lancé à la main sansjournalne publie plus rien dans le store : aucune ligne n'autorise ses sorties (il garde l'artefact du run). -
Gardes :
registry.test.ts(mécanisme par préséance, cliquetenv, capacités par portée, aucune valeur d'allure secrète),no-vendor-import.test.ts,resolve.test.ts, les suites de contrat,schema.test.ts(les comptes suivent), etcredentials.test.ts: tout nom du schéma qui a l'allure d'un crédit (/_(KEY|TOKEN|SECRET|PASSWORD|COOKIE|CREDENTIALS?)$/) est nommé par une liaison, déclaré secret interne avec sa raison, classéLOCAL_ONLY(jamais posé dans Vercel :AI_GATEWAY_API_KEY,CINEMA_SESSION_COOKIE), ou en sursis avec l'étape qui le retire ; une listePUBLIC_VALUESexempte les valeurs publiques (BLOB_WEBHOOK_PUBLIC_KEY, si elle est un jour déclarée) ; un nom retiré entre dansRETIRED_CREDENTIALSet ne peut plus revenir. Côté runner : la matrice de vérification du jeton GitHub (émetteur, audience, dépôt, workflow, run, expiration, algorithme) refuse tout écart avec zéro URL rendue.
Alternatives écartées
- Garder des variables d'environnement, même sensibles. Une rotation demande un redéploiement, les copies divergent (Blob et rappel, déjà deux fois), aucune isolation par produit n'est possible. Le fondateur a demandé l'inverse.
- Un coffre externe (Doppler, 1Password, Infisical). C'est un fournisseur de plus, avec un secret racine de plus à coller, alors que Connect est enraciné dans l'OIDC du déploiement.
- Des liaisons éditables dans le cockpit (une table). Une liaison décide où part un brouillon : elle se relit en PR et se teste en CI. Le cockpit ne doit pas pouvoir rediriger la production d'un produit vers les canaux d'un autre en un clic. Il ne fait que détacher et rattacher.
- Un bloc
connectorsdans chaque produit deproducts.json. Les liaisons d'atelier (messagerie, Blob, IA) n'appartiennent à aucun produit.products.jsona un schéma strict, un numéro de version que la CLI vérifie (version === 2) et unregistryVersionen base. Le WP5 l'édite au même moment (references.shopDomains). Un fichier voisin évite les trois.postiz.integrationsquitteproducts.jsonà l'étape C5 et devientpublisher.config.channels. - Un port par fournisseur (
PostizClient,DatafastClient). C'est ce qui existe : changer de fournisseur y veut dire réécrire les appelants. - Réutiliser
connect-content.tsx, ou ajouter une section au rail. Voir e). - DataFast par OAuth et MCP comme adaptateur par défaut (Connect par URL,
datafa.st/api/mcp). Ces jetons ne servent que MCP ; la connexion expire au plus tard à 90 jours, avec une reconnexion humaine ; le sujet est un utilisateur, lié au fondateur ; le budget est de 60 requêtes par minute. Pour un board serveur en lecture, un jetondft_en lecture seule, borné à un site, rangé dans Connect et tourné sans redéploiement, est la meilleure option. L'adaptateurdatafast-mcpreste documenté comme alternative (sujetfounder, geste « Autoriser »). - Postiz par le connecteur OAuth générique. Pas de découverte, un échange en JSON, pas de PKCE, des
jetons sans expiration : le pilote standard ne sait pas faire. Importer un jeton
pos_parimportConnectorTokensdonnerait un secret sans fin, comme une clé, avec plus de pièces mobiles. - Une clé AI Gateway budgétée (plan moteur G2-26). Le budget de projet plafonne l'OIDC du projet, et une clé n'est jamais comptée dans un projet. Ni l'un ni l'autre ne réserve de solde : tant que le studio et la plateforme partagent l'équipe, l'un peut épuiser le solde de l'autre ; seules deux équipes les séparent (décision du fondateur). La clé ne garde qu'un usage : un poste hors de Vercel.
@remotion/vercel: il exige Remotion 4.0.426 ou plus. Le monter est une décision distincte (licence, React 18, compositions), pas un effet de bord des connecteurs.- Les flags comme source de vérité du fournisseur : rien ne serait relu, et un flag pourrait pointer un adaptateur jamais testé. Un flag choisit parmi ce que le registre déclare.
- Un secours partout. Seule la messagerie en a un, parce que l'OTP est la seule porte. Ailleurs, une panne de Connect rend un geste momentanément absent, ce qui est acceptable. Une clé de secours permanente serait une seconde copie.
- L'app GitHub gérée par Vercel. Elle est faite pour le travail sur les issues et les PR (« create, update, label »), ses permissions ne sont documentées nulle part, et une installation donne les mêmes droits sur chaque dépôt choisi. Remplacée par deux apps propres, choisies d'emblée.
- Une seule app GitHub, avec un jeton rétréci par requête (
authorizationDetailsde typegithub_app_installation). Le rétrécissement est choisi par l'appelant : une Preview qui exécute le code d'une PR, ou un poste lié, peut demander l'installation entière. Il reste une défense en profondeur dans les adaptateurs, jamais une frontière. - Un secret HMAC partagé pour le runner, ou un jeton de lecture collé dans GitHub. L'OIDC de GitHub Actions prouve le run sans secret, et le studio rend des URL présignées bornées : rien de durable ne reste dans GitHub, et cela ne dépend pas de la décision Sandbox.
vercel env pullpour le développement local. Il écrit en clair sur les postes toute variable ciblée sur Development, et fait basculerpnpm deven direct. Remplacé parvercel project tokenet des connecteurs de Development en lecture seule.- Le secours de la messagerie limité à « Connect injoignable ». Un connecteur détaché, ou un uid qui ne correspond pas, laisserait le fondateur dehors même après avoir posé le secours : le runbook d'incident échouerait. Le secours sert donc pour tout échec de crédit ou de Connect, jamais pour un message refusé ni une limite de débit.
Conséquences
Ce qui change pour le fondateur
Il ne colle plus un jeton dans les variables d'un projet ni dans les secrets de GitHub. Il crée un connecteur
dans l'onglet Connect de Vercel, l'attache au projet studio pour les environnements voulus, et le cockpit dit
s'il marche. Il fait tourner une clé sans redéployer. Il coupe une capacité en un clic (« Détacher »). Il change
de fournisseur par une PR d'une ligne, quand l'adaptateur existe et que la capacité est dans la portée de la
promesse. Les gestes exacts, service par service, sont dans CONNECTORS-PLAN.md, section 5 (« Founder
actions »).
Ce que cela coûte
- Connect : quelques dollars par mois au volume du studio (quelques centaines de générations, avec le cache par instance et la disponibilité sans jeton), selon les prix relevés par A.
- Sandbox, si C7 est retenue : environ 0,06 $ par rendu de 5 minutes, selon A, plus la préparation d'un
instantané à chaque changement du lockfile de Remotion (hors minutes facturées au membre). Le forfait interne
des minutes de runner doit alors suivre (
RUNNER_*_PER_MINUTEpar type de runner, dans le ledger du WP2). - Un paquet de plus (
@orbit/connectors), une table (StudioConnectorHealth), une migration de noms neutres (ContentReview.externalPostIdetpublisher,ContentRender.publisher), deux routes machine pour le runner. - Des réponses enregistrées à maintenir pour chaque adaptateur (synthétisées depuis la documentation).
Risques et parades
| Risque | Parade |
|---|---|
| Connect indisponible : plus d'OTP, donc plus personne n'entre | secours de la messagerie (CONNECTOR_STUDIO_MAILER, vide par défaut ; en incident, une clé Resend neuve posée en Production, puis redéploiement ; après, suppression, redéploiement et révocation de cette clé) ; le cache par instance ; le tableau de bord Vercel reste indépendant de l'app |
| Une clé AI Gateway oubliée ou remise dans Vercel retire le plafond du projet en silence | la résolution la refuse sur Vercel (invalid), /api/health le dit ; le fondateur la supprime des trois environnements |
| Le solde d'équipe, partagé avec la plateforme, s'épuise | le budget de projet plafonne le studio ; un budget sur la clé de la plateforme, côté plateforme ; seules deux équipes séparent les soldes (décision) |
| Rotation Postiz : l'ancienne clé meurt aussitôt | toUpdate dans Connect dans la minute ; une organisation que la plateforme n'utilise pas |
| Quota Postiz par organisation (30 ou 90 requêtes par heure) | statut sans appel, listChannels gardé 10 minutes, test plafonné, une sonde en un appel |
| Crédits de production sur un poste | Development ne reçoit rien ; vercel project token ; auto vaut fixtures hors de Vercel ; jamais vercel env pull |
| Jeton de clé API resté en cache après une rotation | 401, puis invalidation et un rejeu ; valeur remplacée en place |
| Une Preview qui pose des brouillons ou lance un rendu | publication et rendu attachés à Production seulement |
| Une Preview de PR qui écrit dans le store ou la base de production | risque résiduel : protection de déploiement des Previews, relecture des PR |
| Une app GitHub trop large | deux apps propres, dépôts choisis, rendu en Production seulement |
| DataFast par MCP (s'il est choisi) : reconnexion humaine à 90 jours | adaptateur REST par défaut ; consent-required affiché avec « Autoriser » |
| La transplantation ou le moteur réécrivent les fichiers touchés | ordre et fenêtres de CONNECTORS-PLAN.md ; C1 ne touche aucun fichier des deux branches ; les observations pour les deux autres plans |
Retirer BLOB_READ_WRITE_TOKEN casserait les téléversements du navigateur ou l'affichage | il reste dans Vercel pour la seule route handleUpload ; la CSP lit le store dans le registre, jamais le jeton ; le moteur passe l'OIDC explicitement |
| Un secret copié à la main dans GitHub | C3b les retire tous : OIDC de GitHub Actions et URL présignées |
Variables retirées et mises au ban
À chaque étape, les noms retirés quittent le schéma et .env.example, les comptes de schema.test.ts
(« 21, 6, 12 », carte §5) sont mis à jour en citant cette ADR, et les noms entrent dans
RETIRED_CREDENTIALS (apps/web/src/connectors/credentials.test.ts, C2), qui refuse leur retour. Pas
dans FORBIDDEN_ENV : cette liste est figée à six entrées que la règle 2 de CLAUDE.md recopie mot pour
mot (schema.test.ts le vérifie), et elle dit autre chose, les secrets de la plateforme et les
fournisseurs retirés. Ici le fournisseur reste, seul le chemin du crédit change. En fin de parcours,
restent dans Vercel : des variables non secrètes (ids, origines), les secrets internes (NEXTAUTH_SECRET,
CRON_SECRET), les variables posées par Neon, Upstash et le store Blob (BLOB_STORE_ID, lu par le seul contrôle
de disponibilité ; BLOB_WEBHOOK_PUBLIC_KEY ; BLOB_READ_WRITE_TOKEN pour la seule route handleUpload), et le
secours CONNECTOR_STUDIO_MAILER, vide. AI_GATEWAY_API_KEY et CINEMA_SESSION_COOKIE restent déclarées,
LOCAL_ONLY : un poste seulement, jamais Vercel. GitHub ne garde aucun secret.
Ce qui reste à mesurer (expériences bon marché, réversibles)
- GitHub : pour une app propre, créer le connecteur d'abord, puis installer l'app depuis Vercel, pour que
Connect enregistre l'installation ; et si
authorizationDetailsde typegithub_app_installationrétrécit bien le jeton (format depermissions). getTokenResponsesur un connecteur à clé API : la valeur deexpiresAt(durée de vie en cache).vercel connect token <uid> --subject appdepuis la CLI d'un membre, avec un connecteur attaché à Development.vercel connect create resend --help: la méthode « clé API » en sujetapp.- Postiz : une seconde organisation pour les comptes personnels est-elle possible avec l'abonnement actuel, et l'onglet Developers (plan Standard et plus) y est-il ouvert ? Et la vraie limite (30 par heure, ou 90 sur la seule création).
- Le rôle d'équipe qui peut créer et attacher des connecteurs (le MCP Vercel de cette session a reçu
403 sur
list_connectors) : Owner, ou une permission Connect donnée à un membre. - Workflows : comment le runtime authentifie ses livraisons vers
/.well-known/workflow/v1/flowet/step(préalable de C7). - Sandbox : l'API exacte d'une commande détachée dans le SDK TypeScript, et la durée maximale d'une session sur le plan.
experimental_startInstallation: ouvert à l'équipe ou non (demande à Vercel).getConnectorMetadata: confirmer qu'il n'est pas facturé comme une demande de jeton.- Le format de
BLOB_STORE_IDque pose Vercel (avec ou sans préfixestore_, que le SDK retire).
Résolution de la critique
| # | Sévérité | Réponse | Où |
|---|---|---|---|
| 1 | haute | appliqué | c) étape 4 oidc (règle des préséances silencieuses) ; messages invalid ; PLAN C3, F2 |
| 2 | haute | appliqué | Corrections, 3 ; alternative 9 ; risques ; PLAN §0.3, F0, F2 |
| 3 | haute | appliqué, sauf la conclusion « une app suffit » (rejet 1) | f) moindre privilège ; alternatives 13 et 14 ; SourceRepo ; PLAN F5a, F5b, C7 |
| 4 | haute | appliqué | Faits fournisseurs ; table de rotation ; Publisher ; PLAN F8, C5, rotation |
| 5 | haute | appliqué | c) mode et développement local ; f) environnements ; alternative 16 ; PLAN F3, F6, C3 |
| 6 | haute | appliqué (nouvelle étape C3b) | Décision ; d) OIDC de GitHub Actions ; f) runner ; alternative 15 ; PLAN C3b, F12 |
| 7 | haute | appliqué (params.runner plutôt qu'une colonne) | a) RenderRunner ; d) Sandbox, Workflows, Flags ; f) Sandbox ; PLAN C7 |
| 8 | moyenne | appliqué | a) BlobStore ; c) règle des préséances silencieuses ; PLAN C3 |
| 9 | moyenne | appliqué | c) le garde ; forbidden ; PLAN C1, C2, C5, C6 |
| 10 | moyenne | appliqué sous une autre forme (rejet 2) | règle 5 des ports ; table de rotation ; PLAN C2, C4, rotation |
| 11 | moyenne | appliqué | a) KeyValue et RateLimiter ; PLAN C2 |
| 12 | moyenne | appliqué | b) portée de la promesse ; a) Voice ; PLAN C3 |
| 13 | moyenne | appliqué | e) une seule section « Connecteurs » ; PLAN C4 |
| 14 | moyenne | appliqué | e) phrases vendues ; b) ; PLAN C5 |
| 15 | moyenne | appliqué, source changée par le constat 23 | risques ; PLAN C3 (headers.ts lit le registre) |
| 16 | moyenne | appliqué | c) gestes que les connecteurs rallument ; PLAN C3 |
| 17 | moyenne | appliqué | c) étape 2 ; b) capacités d'atelier ; PLAN C1, C4 |
| 18 | moyenne | appliqué et étendu (rejet 4) | c) secours ; alternative 17 ; risques ; PLAN C2, F1, runbook |
| 19 | moyenne | appliqué | c) étape 1 ; PLAN §1, C2, C3 |
| 20 | moyenne | appliqué | d) Workflows ; PLAN C8 |
| 21 | basse | appliqué | e) ?liaison= ; PLAN C4 |
| 22 | basse | appliqué | d) Image Optimization |
| 23 | basse | appliqué | b) config.storeId, blobLegacy ; table de rotation ; PLAN C3 |
| 24 | basse | appliqué | b) détection des secrets ; f) gardes ; PLAN C1, C2 |
| 25 | basse | appliqué | a) règle 3, SourceRepo, PlatformBridge ; PLAN C6 |
| 26 | basse | appliqué | b) unicité par fournisseur ; PLAN C5 |
| 27 | basse | appliqué | c) cache des jetons ; a) vidéo ; f) ids et jetons |
| 28 | basse | appliqué | c) mode d'échec, coût ; a) RenderRunner sans configured() ; PLAN C4, C6 |
| 29 | basse | appliqué, sans le type dans le motif (rejet 5) | c) plafond de budget ; PLAN C3 |
| 30 | basse | appliqué | Ce que Vercel fournit (uid, déclencheurs, installation, jeton RW) ; en-tête ; e) « Installer » |
| 31 | basse | appliqué | c) CLI ; f) moindre privilège ; PLAN F4 |
| 32 | basse | appliqué ; les hôtes publics sont inventoriés, pas une capacité (rejet 3) | d) KMS, sources publiques ; PLAN C6, C7 |
| 33 | basse | appliqué | en-tête ; PLAN (comptes, STUDIO_MAIL_FROM en C2, F0, F10, C1) |
Constats rejetés
- Constat 3, la conclusion de l'expérience proposée : « Si oui, une app suffit. » Le SDK type déjà un
rétrécissement GitHub (
authorization-details.d.ts:{ type: 'github_app_installation', org?, permissions?, repositories? }), etgetTokenResponseenvoie les paramètres de l'appelant tels quels (token.js, corps dePOST /v1/connect/token/<uid>). Le rétrécissement existe donc probablement, mais il est choisi par l'appelant : le code d'une PR qui tourne en Preview avec le jeton OIDC de Preview, ou un poste lié, peut omettreauthorizationDetailset recevoir l'installation entière. La frontière reste l'installation et l'attachement par environnement : deux apps restent nécessaires. Le rétrécissement est gardé comme défense en profondeur dans les adaptateurs, et l'expérience 1 ne mesure plus que son format. - Constat 10, la procédure « ajouter la nouvelle valeur, supprimer l'ancienne, tester, et en cas d'échec
remettre l'ancienne ». Remettre l'ancienne est impossible : la valeur d'un connecteur à clé API est en
écriture seule (
values[].value,writeOnly: true, schémacreate_connector; le constat 18 c le dit aussi), et un fournisseur ne montre une clé qu'une fois. Le schéma offre mieux que deux opérations :toUpdate, qui remplace la valeur stockée en place (« Replacement API key value », schémaupdate_connector). La procédure retenue remplace en place, teste, puis révoque ; un échec se corrige en avançant (une clé corrigée), l'ancienne clé restant valide chez le fournisseur tant qu'on ne l'a pas révoquée. - Constat 32, premier point : faire des hôtes de références une capacité « source » du registre. Ces hôtes
n'ont ni crédit à tourner ni fournisseur à échanger : ce sont des listes d'hôtes publics. Le WP5 les a déjà mis
dans
products.json(references.shopDomains, schéma zod fermé par défaut, hôtes exacts, dix au plus,products/src/index.tsde l'arbre moteur), si bien qu'un déménagement de vitrine est déjà une ligne de registre. Un port sans crédit et sans alternative n'ajouterait aucune capacité d'échange. Ils sont inventoriés en d), « Sources publiques sans crédit », avec les hôtes de préparation de la microVM. - Constat 18, la règle finale implicite : une fois la règle transitoire retirée en C3, le secours ne servirait
plus que pour « Connect injoignable ». Sous cette règle, le runbook d'incident du constat lui-même échoue
quand la panne est un
not-linked(projet détaché dans Vercel, uid qui ne correspond pas) : le fondateur pose une clé neuve, redéploie, et ne reçoit toujours pas de code. La règle finale couvre donc tout échec de crédit ou de Connect (c, « Secours »), jamais un message refusé ni une limite de débit ; la variable, vide par défaut et signalée dès qu'elle est posée, reste le garde-fou contre un secours oublié. - Constat 29, ajouter
quota_for_entity_exceededaux motifs de solde. Ce type ne traverse pas l'AI SDK 7 : le fournisseur Gateway transforme un type inconnu enGatewayInternalServerErrorqui ne garde que le message et le statut (@ai-sdk/gateway4.0.94,dist/index.js, branchedefaultduswitch (errorType)). Le moteur classe donc un plafond atteint par le message (/\bbudget exceeded\b/i) et par le statut 402 d'une erreur du Gateway ; le reste du constat est appliqué.