Décisions fondatricesADR-0016 · Service tiers = connecteur

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 registre products/connectors.json d'Orbit (liaisons studio.*, "products": {}), le dépôt de rendu BoostEcom/orbit.easyconnector.app. Les exemples de liaisons de produit sont écrits avec l'id orbit ; Orbit n'a pas de plateforme parente : le port PlatformBridge (capacité platform) est supprimé du code (CaptureSecret reste, 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 variables STUDIO_* 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.mailer passe de { "connect": "resend/studio" } (avec le secours RESEND_API_KEY) à { "marketplace": "resend" }, qui lit RESEND_API_KEY injecté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 accepte marketplace pour l'adaptateur resend, en premier dans son ordre de préséance (la préséance d'un adaptateur est désormais l'ordre de ses accepts, 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, registre products/connectors.json) est décrite dans Connecteurs Studio, qui note aussi ses écarts avec ce texte, rédigé avant la fusion du moteur sur main (la vidéo asynchrone y est désormais, d'où une liaison videoJobs dè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.md et B-services-inventory.md (dossier de travail du plan, hors dépôt) ; le studio à origin/main bdcb518 ; la branche moteur studio/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/blob 2.8.0, route de téléversement, hôte Blob de la CSP) ; la branche de transplantation studio/06-design-plateforme (étape 1 committée, c4f5de6) ; la plateforme à 8df697416 ; les paquets @vercel/connect 2.3.3, @vercel/blob 2.8.0 et @ai-sdk/gateway 4.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) et port/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.ts lit AI_GATEWAY_API_KEY, ELEVENLABS_API_KEY, BLOB_READ_WRITE_TOKEN, CREATIVE_RENDER_REPO et ORBIT_RENDER_DISPATCH_TOKEN. lib/mail/transport.ts lit RESEND_API_KEY et STUDIO_MAIL_FROM, lib/security/kv.ts lit KV_REST_API_*, et rate-limit.ts, lockout.ts et once.ts appellent le client Upstash que getRedis() leur rend (rate-limit.ts importe aussi @upstash/ratelimit). Les scripts de packages/remotion lisent process.env directement (postiz.mjs, tts.mjs, publish-artifact.mjs, callback.mjs). Il n'y a d'indirection que pour trois interfaces du moteur : StudioProviders, BlobStore et RenderConfig.
  • 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_TOKEN et PLATFORM_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_URL et KV_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.json porte un postiz.integrations par 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_SECRET et CREATIVE_RENDER_CALLBACK_URL dans les secrets du rendu (render.yml:133,145,154-155), et PRODUCT_REPOS_READ_TOKEN pour 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éclare POSTIZ_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_KEY d'abord, l'OIDC ensuite, @ai-sdk/gateway 4.0.94). Le mode auto passe en direct dès que VERCEL_OIDC_TOKEN est présent hors production, et blobStore() 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 REST create_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/connect 2.3.3 (lu dans le paquet publié). Il exporte getToken, getTokenResponse (token, tokenId, expiresAt, connector { id, uid, type }), deleteTokenCacheEntry, revokeToken, startAuthorization (authentifiée par le jeton OIDC, donc appelable depuis une action serveur), getConnectorMetadata et experimental_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). forceRefresh court-circuite ce cache et fait revalider le droit par Connect. deleteTokenCacheEntry est 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).
    • getConnectorMetadata confirme 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, avec code, status et vendor), NoValidTokenError (no_token), UserAuthorizationRequiredError et ConnectorInstallationRequiredError. Un connecteur absent, non attaché au projet ou non activé pour l'environnement arrive en ConnectError générique, avec son status et son code.
    • ConnectTokenParams.authorizationDetails connaît un type GitHub, { type: "github_app_installation", org?, permissions?, repositories? } (authorization-details.d.ts), et getTokenResponse envoie 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 des serviceUrls (1 à 8 URL HTTPS), un subjectType app (une clé pour l'application) ou user (chaque utilisateur colle la sienne), et des values[] avec expiresAt et scope facultatifs. La valeur est en écriture seule (writeOnly) : Connect ne la rend jamais. Elle se gère par toAdd, toDelete et toUpdate, qui remplace une valeur stockée en place (par son id : « Replacement API key value »).
    • Le connecteur github accepte une app GitHub propre (appId, appSlug, appName, clientId, privateKeyPem et webhookSecret en é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 uid s'écrit <service>/<nom> pour un service du catalogue (resend/…, elevenlabs/…, github/…) et api-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éma update_connector). L'uid vit 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.
  • 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 en GatewayInternalServerError, avec le message et le statut 402, sans le type (@ai-sdk/gateway 4.0.94, branche default). 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/blob 2.8.0, code lu). resolveBlobAuth essaie dans l'ordre : une charge présignée, un token explicite, l'OIDC (le oidcToken passé, sinon getVercelOidcToken() enveloppé pour avaler toute erreur) avec storeId ou BLOB_STORE_ID, puis retombe en silence sur BLOB_READ_WRITE_TOKEN s'il est posé (chunk-YYMLUMXS.js:62-69,160-205). Avec un oidcToken explicite et sans identifiant de store, il lève au lieu de retomber (:193-195). Connecter un store à un projet y pose BLOB_STORE_ID (un identifiant), VERCEL_OIDC_TOKEN et BLOB_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 et handleUpload. issueSignedToken (par OIDC ou jeton RW : chemin, opérations, validUntil de 7 jours au plus, 1 h par défaut, types permis, taille maximale) puis presignUrl donnent des URL présignées GET, HEAD, PUT ou DELETE. Le protocole de téléversement présigné existe (handleUploadPresigned), mais son client est uploadPresigned, pas upload(). 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ès snapshot(), 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 par withWorkflow(nextConfig) (workflow/next) ; le runtime sert POST /.well-known/workflow/v1/flow et /.well-known/workflow/v1/step, plus /.well-known/workflow/v1/webhook/:token pour createWebhook, dont le jeton est la seule autorisation. createHook se reprend (resumeHook) depuis une route à nous. sleep ne 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 écrit VERCEL_OIDC_TOKEN et 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-core s'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:read seule) et à une liste de sites. Une clé de site df_ 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 propre POSTIZ_API_KEY (src/services/growth/postiz.ts:98). L'OAuth n'a ni découverte, ni PKCE, ni expires_in, ni jeton de rafraîchissement ; l'échange de code se fait en corps JSON, et les jetons pos_ n'expirent pas. En-tête Authorization: <clé> sans Bearer. GET /is-connected vérifie une clé sans effet de bord et GET /groups liste 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é sans repositories ni permissions a « 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: 0 ou retry-after). Le jeton OIDC d'Actions porte repository, repository_owner, workflow_ref, ref, run_id, et se vérifie contre https://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/vercel existe depuis la 4.0.426, alors que le studio épingle Remotion 4.0.242.

Corrections apportées aux rapports A et B

  1. 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.status et code.
  2. 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.
  3. 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_KEY is 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.
  4. Blob : la variable de l'identifiant de store est BLOB_STORE_ID, et Vercel la pose. Le client vendu gallery-context.tsx appelle upload() de @vercel/blob/client vers /api/studio/reference-upload. Ce protocole exige handleUpload, donc le jeton RW, et le fichier est vendu octet pour octet : le studio ne peut pas le passer à uploadPresigned. BLOB_READ_WRITE_TOKEN reste donc dans Vercel, lu par cette seule route (liaison uploads), jusqu'à ce que la plateforme change de client, puis pnpm 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.
  5. 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, tout packages/remotion). La microVM exécute le pipeline existant.
  6. B écrivait « tous les ref à null » : sur origin/main, le produit de la plateforme est épinglé (8df697416…) et référence son DESIGN.json. Les quatre autres produits sont bien à null.
  7. B ne listait pas ContentReview.postizPostId, voir plus haut.
  8. 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 :

CoucheCe qu'elle décideOùQui la change
Liaisonquel 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éditla valeur secrète et qui a le droit de la demanderVercel : 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
Étatprêt, détaché, dernier succès, dernier échecla base du studio et son journalle cockpit (/ops/connecteurs)

a) Les ports : une interface par capacité, jamais par fournisseur

Règles :

  1. Un port porte le nom d'une capacité (Publisher, Analytics, Mailer, ...). Aucun type d'un fournisseur ne le traverse : pas de settings.__type Postiz, pas de providerOptions, pas d'id pos_, pas de catégorie de voix ElevenLabs. Ce qui est propre au fournisseur reste dans son adaptateur.
  2. Le port appartient à celui qui le consomme. Les ports du moteur restent dans @orbit/engine et 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, KeyValue et RateLimiter, UploadIssuer, PlatformBridge, CaptureSecret.
  3. 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 : sourceRepo est une capacité d'atelier dont les méthodes prennent une RepoRef résolue par products.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ôt Ecosystem, chacun sur sa branche) ; PlatformBridge n'accepte qu'une liste fermée de chemins.
  4. Les échecs attendus sont des valeurs (Result), jamais des exceptions. Les ports du moteur gardent leur contrat par exception, déjà traduit par describeStudioFailure.
  5. 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 (provider ou credential) et le tokenId Connect 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 appelle probe().
  6. 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

  • Secret dans le moteur. packages/engine/src/secret.ts dé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.

  • BlobStore garde 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 par getVercelOidcToken() de @vercel/oidc, qui lève quand il n'y en a pas) et storeId (celui de la liaison). Le SDK lève alors au lieu de retomber sur BLOB_READ_WRITE_TOKEN. ownsUrl accepte l'hôte du store déclaré, et celui de blobLegacy pendant un déménagement (b).

  • StudioProviders (image, vidéo, vecteur, voix) garde son interface. Sa configuration change : liveProviders({ gatewayConfigured, elevenLabsKey, publicUrl }) (forme du WP3) devient liveProviders({ gateway, voice, publicUrl }), où gateway vaut { mode: "oidc" } (ou { mode: "key"; key }, hors de Vercel seulement, c) et où voice est un adaptateur du port Voice, 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 : premade et generated donnent stock, toute autre catégorie requires-consent, une lecture échouée unknown ; il traduit aussi VoiceSettings en corps de requête (le body: Record<string, unknown> brut de VoiceRequest disparaî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 »).

  • RenderRunner remplace RenderConfig et les fonctions libres de render/dispatch.ts (launchTemplateRender, listRenderRuns, readRunJobs, le readRun du 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é de params.workflowRunId), et inspect comme le balayage choisissent l'adaptateur d'après cette valeur, jamais d'après le flag (d). L'adaptateur github-actions garde render.yml, return_run_details et la signature du quota ; il demande un jeton Connect à chaque appel, rétréci au dépôt de rendu (aujourd'hui BoostEcom/orbit.easyconnector.app) et actions: write. L'adaptateur vercel-sandbox viendra à l'étape C7 ; il reçoit startWorkflow par 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 en 5cc0fba). Son crédit est celui de la liaison ai (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, blob et blobLegacy, ai, videoJobs, voice, sourceRepo, render) : déclarées sous studio. Un produit ou libre ne 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 sous studio ni libre. 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 » de postiz.integrations).
  • Une liaison, c'est un adapter (connu du catalogue pour cette capacité), un credential, une config validée par l'adaptateur, puis en option une alternate et un breakglass (Connect seulement, et seulement pour mailer). La config porte 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 champ waiver écrit la raison ; capacités par portée ; aucune valeur d'allure secrète ; le nombre de liaisons env ne peut que baisser, un cliquet sur le modèle des plafonds de la plateforme) et apps/web/src/connectors/credentials.test.ts (un nom env ou breakglass suit ^CONNECTOR_[A-Z0-9_]+$, les noms hérités ne passant qu'avec waiver, 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) :

  1. écrire l'adaptateur (apps/web/src/connectors/adapters/<fournisseur>.ts) et l'inscrire au catalogue, avec son schéma de configuration ;
  2. créer le connecteur dans Vercel et l'attacher au projet, pour les environnements voulus ;
  3. changer une ligne de products/connectors.json ;
  4. 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 :

  1. Vercel Connect partout où il sert le fournisseur (OAuth, clé API, MCP, app GitHub) : getTokenResponse(uid, { subject }), authentifié par l'OIDC du déploiement.
  2. 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.
  3. Fédération OIDC pour les services Vercel eux-mêmes : AI Gateway, Blob, Sandbox, Workflows, Flags.
  4. Une variable sensible nommée par connecteur (CONNECTOR_<PORTÉE>_<CAPACITÉ>), en dernier recours, avec un waiver é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 :

  1. Mode. Un seul prédicat, isVercelDeployment() : VERCEL_ENV vaut production ou preview (vercel dev et vercel env pull posent development, qui n'en est pas). Il sert à providerMode, mailTransport, blobStore et resolveCredential, à la place de NODE_ENV === "production", que next start sur 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 soit ORBIT_PROVIDERS ; les autres connecteurs de l'application et le moteur suivent ORBIT_PROVIDERS, où auto vaut fixtures (même avec un VERCEL_OIDC_TOKEN) et live est un choix explicite. En test, fixtures partout, aucun réseau. Sur Vercel, les fixtures sont refusées.
  2. 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:voice pour 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.
  3. État studio. Si StudioConnectorHealth(<liaison déclarante>).disabledAt est posé : disabled.
  4. Mécanisme déclaré :
    • connect : getTokenResponse. Les erreurs se traduisent ainsi : UserAuthorizationRequiredError en consent-required, ConnectorInstallationRequiredError en installation-required, NoValidTokenError en no-token, un ConnectError 401, 403 ou 404 en not-linked (le code est gardé pour le message). Une erreur réseau, un 429 ou un 5xx devient connect-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 un rejected ou forbidden du fournisseur), jamais pour un message refusé sur son contenu ni pour rate-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/health et 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. Pour vercel-blob, le store que désigne le jeton RW doit être celui de config.storeId, sinon invalid.
    • oidc : vérifie qu'un jeton OIDC est obtenable (getVercelOidcToken de @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 : oidcToken et storeId à chaque appel) ; quand il ne le peut pas, la résolution refuse la variable sur Vercel. C'est le cas du Gateway, qui prend toujours AI_GATEWAY_API_KEY avant l'OIDC (getGatewayAuthToken) : sur un déploiement Vercel, une liaison oidc: "ai-gateway" exige que AI_GATEWAY_API_KEY soit absente. Sinon la résolution rend invalid (« Une clé AI Gateway est posée dans Vercel : elle contourne le budget du projet. La supprimer, puis redéployer. »), les capacités ai et videoJobs sont refusées et /api/health le signale.
    • env : lit la variable nommée. Vide : not-configured.
  5. Cache négatif. Tout échec autre que connect-unreachable est 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_MS du WP3 ;
  • sur un 401 seulement, avec un crédit connect, il appelle invalidate() (deleteTokenCacheEntry avec 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 devient forbidden (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 avec x-ratelimit-remaining: 0 ou retry-after est rate-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 charge ConnectError.vendor.

Développement local et tests :

  • Tests (pnpm test) : fixtures partout. resolve.test.ts couvre la traduction de chaque erreur, avec vi.mock("@vercel/connect"). Aucune suite ne touche le réseau.
  • Tests de contrat des adaptateurs réels : un fetch rejoué 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 jamais vercel env pull dans apps/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)", puis ORBIT_PROVIDERS=live pnpm dev. Seuls répondent les connecteurs attachés à Development, tous en lecture seule (github/product-repos en 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-dev porte 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 app est refusé depuis la CLI d'un membre (expérience 3), repli : une clé personnelle dans le shell, classée LOCAL_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écanismeTournerRedé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 foisnon
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'autrenon
Connect, OAuthConnect rafraîchit seul. Le secret client tourne dans le connecteur (clientSecret, en écriture seule)non
Connect, app GitHub propreGitHub accepte plusieurs clés privées par app : générer la nouvelle, la poser dans le connecteur (privateKeyPem), « Tester », supprimer l'ancienne chez GitHubnon
Marketplace (Upstash, Neon)rotation par l'intégrationoui : « old secrets may still be used … until a manual redeployment »
Jeton RW du store Blobrotation 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 à tournernon
Variable sensible (le secours de la messagerie)nouvelle valeuroui

Révocation, de la plus rapide à la plus forte :

  1. Détacher dans le studio (cockpit) : la liaison passe disabled, le geste disparaît à la requête suivante, et c'est journalisé.
  2. 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.
  3. 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.
  4. revokeToken du 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).

RaisonMessage au fondateurOù corriger
unboundAucun connecteur n'est lié à cette capacité pour ce produit.products/connectors.json, par PR
disabledDétaché dans le studio le {date} par {email}./ops/connecteurs?liaison={liaison}, « Rattacher »
not-linkedLe connecteur {uid} n'est pas attaché à ce projet pour l'environnement {env}.Vercel, onglet Connect, {uid}, Projets
consent-requiredAutorisation requise.cockpit, « Autoriser »
installation-requiredInstallation requise chez le fournisseur.cockpit, « Installer » quand Vercel l'a ouvert à l'équipe ; sinon Vercel, onglet Connect, {uid}
no-tokenAccès révoqué ou expiré.Vercel, onglet Connect, {uid}
rejectedLe fournisseur refuse la clé : elle a été révoquée ou remplacée.fournisseur, puis Vercel, onglet Connect, {uid}, valeur
forbiddenLe 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-unreachableVercel Connect ne répond pas, nouvel essai dans 30 s.rien (la messagerie a son secours)
rate-limitedQuota 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 studioCe qu'elle remplaceÉtape
Connectcré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 vitrineRESEND_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_KEYC2, C3, C5, C6
OIDC + budget de projet AI Gatewayimages, 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éestout fichier que le serveur écrit, lit ou supprime ; sorties du runner (C3b) et de la microVM (C7) ; média remis à un planificateurl'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éeCREATIVE_RENDER_CALLBACK_SECRET, la copie GitHub du jeton RW, PRODUCT_REPOS_READ_TOKEN (secret GitHub)C3b
Sandboxexé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 balayageC7 (décision du fondateur)
Workflowsorchestrer 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 exactsle sondage du balayage (le cron garde les réserves périmées et une réconciliation quotidienne)C7, C8
Queuespas d'usage direct : les Workflows reposent dessus. Plus tard peut-être, l'éventail d'un lot de la Fabriquerienaucune
Flagschoisir 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'adapterdes variables STUDIO_*_BACKEND qu'on aurait ajoutéesC7
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 plateformeun secret partagé (CAPTURE_BRIDGE_SECRET, PLATFORM_READ_TOKEN)note en C6
Image Optimizationaucun 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 Blobrienaucune
CDN et étiquettes de cachefichiers de rendu immuables (max-age long). Aucune route du studio n'en a besoin aujourd'huirienaucune
MarketplaceNeon (base) et Upstash (KV), injectés par leur intégration, attachés à Production et Preview seulementrien : c'est déjà le bon cheminC2 (déclaration)
Déclencheurs Connectaucun usage : le connecteur GitHub géré ne propose que pull_request, et la garde croisée reste hebdomadairerienaucune
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 à échangerrienC7 (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 le BoardContent vendu) 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 »), ton good ou warning, et href vers la page de la liaison pour qui tient connectors.manage. La Plateforme gagne une carte lien « Connecteurs » vers /ops/connecteurs, dans STUDIO_OPS_ROUTES, comme la carte Membres. Ce n'est pas un board : les OpsSectionId du module sont une union fermée, dans un fichier vendu.
  • Une seule section « Connecteurs » à l'écran. Le StoreSystemDashboard vendu, 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 »), puis pnpm ui:sync et retirer la règle.
  • Agir, sur une page composée du seul kit admin vendu, comme /ops/membres :
    • /ops/connecteurs : AdminPage, puis AdminPageHeader (« Connecteurs »), puis AdminSection « Liaisons » contenant un AdminDataTable avec 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> : le pageTitle vendu (formatSegment du dernier segment) titre alors la page « Connecteurs », jamais Orbit:publisher. Le détail montre AdminDetailHeader, AdminSection « État » (une AdminStatGrid : statut, dernier succès, dernier échec, dernier test et sa profondeur), puis « Tester » (un AdminFormShell qui appelle probe()), « Envoyer un e-mail d'essai » pour la messagerie, « Autoriser » (sujet founder et statut consent-required : startAuthorization avec callbackUrl vers cette page), « Installer » (statut installation-required, si Vercel a ouvert experimental_startInstallation à l'équipe ; c'est le geste le plus proche de « depuis l'app avoir un connector »), « Détacher » ou « Rattacher » (ConfirmActionDialog en 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 studio sont surchargées par le mécanisme de la tranche (PORT l'emploie déjà 13 fois) : studio.detail.draft « Brouillon Postiz » devient « Brouillon », et studio.detail.draftTitle devient « Créer un brouillon chez le planificateur. Rien n'est publié : un humain relit et programme. » (la plateforme dit déjà « le planificateur » dans studio.fabrique.shipHint). Les littéraux reason des 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. ApiKeysContent est câblé sur les clés bei_ : il poste sur /api/intelligence/api-keys avec un orgSlug, affiche le préfixe bei_, propose « Créer une clé » et liste les modèles de MODEL_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, puis pnpm 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 ; libre n'a aucune capacité de produit ; sourceRepo ne lit que le dépôt et la branche que products.json donne 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 :

    LiaisonConnecteur (uid attendu)Droits minimauxEnvironnements 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 registreProduction
    orbit.analyticsapi-key/datafast-orbit (sujet app)jeton dft_ : analytics:read seule, 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 storeProduction
    studio.mailerresend/studio (méthode clé API, sujet app)clé Resend « Sending access » limitée au domaine d'envoiProduction, 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 offertDevelopment
    studio.rendergithub/studio-render : app propre « Orbit Studio Render »Actions: read and write, Metadata: read ; installée sur BoostEcom/orbit.easyconnector.app seul ; 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, Ecosystem et le produit distinct easyconnector.app)Production, Preview (Development en option : lecture seule)
    studio.ai, studio.videoJobsOIDCle projet studio, sous son budget de projetProduction, Preview ; un poste par vercel project token, sur choix explicite
    studio.blobOIDC et storeId du registrele store du studio ; URL présignées pour un chemin, un type et une taille, valables 15 minProduction, Preview
    studio.uploadsjeton RW du store, posé par Vercellu 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 Upstashla base Upstash du studioProduction, Preview
    runner (render.yml, design-drift.yml)OIDC de GitHub Actions, vérifié par le studioURL présignées pour les sorties déclarées de sa ligne ; lien d'archive de 5 minuteschez GitHub, branche main

    Une 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.yml appelle un modèle payant. D'où deux apps, et l'app de rendu attachée à Production seulement.

  • Rien de journalisé : Secret rend [secret] partout où on l'imprime ou le sérialise ; detail est nettoyé ; la charge ConnectError.vendor n'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ée token, authorization, apiKey ou secret.

  • 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.com pour l'archive du code au commit déployé, l'hôte des URL présignées pour les sorties, <store>.public.blob.vercel-storage.com pour les entrées, et, pour template=cinema, l'origine de la plateforme et CINEMA_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), repository BoostEcom/orbit.easyconnector.app, workflow_ref exact (render.yml ou design-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 sans journal ne 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, cliquet env, 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), et credentials.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 liste PUBLIC_VALUES exempte les valeurs publiques (BLOB_WEBHOOK_PUBLIC_KEY, si elle est un jour déclarée) ; un nom retiré entre dans RETIRED_CREDENTIALS et 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

  1. 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.
  2. 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.
  3. 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.
  4. Un bloc connectors dans chaque produit de products.json. Les liaisons d'atelier (messagerie, Blob, IA) n'appartiennent à aucun produit. products.json a un schéma strict, un numéro de version que la CLI vérifie (version === 2) et un registryVersion en base. Le WP5 l'édite au même moment (references.shopDomains). Un fichier voisin évite les trois. postiz.integrations quitte products.json à l'étape C5 et devient publisher.config.channels.
  5. Un port par fournisseur (PostizClient, DatafastClient). C'est ce qui existe : changer de fournisseur y veut dire réécrire les appelants.
  6. Réutiliser connect-content.tsx, ou ajouter une section au rail. Voir e).
  7. 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 jeton dft_ en lecture seule, borné à un site, rangé dans Connect et tourné sans redéploiement, est la meilleure option. L'adaptateur datafast-mcp reste documenté comme alternative (sujet founder, geste « Autoriser »).
  8. 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_ par importConnectorTokens donnerait un secret sans fin, comme une clé, avec plus de pièces mobiles.
  9. 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.
  10. @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.
  11. 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.
  12. 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.
  13. 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.
  14. Une seule app GitHub, avec un jeton rétréci par requête (authorizationDetails de type github_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.
  15. 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.
  16. vercel env pull pour le développement local. Il écrit en clair sur les postes toute variable ciblée sur Development, et fait basculer pnpm dev en direct. Remplacé par vercel project token et des connecteurs de Development en lecture seule.
  17. 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_MINUTE par type de runner, dans le ledger du WP2).
  • Un paquet de plus (@orbit/connectors), une table (StudioConnectorHealth), une migration de noms neutres (ContentReview.externalPostId et publisher, 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

RisqueParade
Connect indisponible : plus d'OTP, donc plus personne n'entresecours 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 silencela 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'épuisele 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ôttoUpdate 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 posteDevelopment 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 rotation401, puis invalidation et un rejeu ; valeur remplacée en place
Une Preview qui pose des brouillons ou lance un rendupublication et rendu attachés à Production seulement
Une Preview de PR qui écrit dans le store ou la base de productionrisque résiduel : protection de déploiement des Previews, relecture des PR
Une app GitHub trop largedeux apps propres, dépôts choisis, rendu en Production seulement
DataFast par MCP (s'il est choisi) : reconnexion humaine à 90 joursadaptateur REST par défaut ; consent-required affiché avec « Autoriser »
La transplantation ou le moteur réécrivent les fichiers touchésordre 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'affichageil 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 GitHubC3b 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)

  1. 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 authorizationDetails de type github_app_installation rétrécit bien le jeton (format de permissions).
  2. getTokenResponse sur un connecteur à clé API : la valeur de expiresAt (durée de vie en cache).
  3. vercel connect token <uid> --subject app depuis la CLI d'un membre, avec un connecteur attaché à Development.
  4. vercel connect create resend --help : la méthode « clé API » en sujet app.
  5. 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).
  6. 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.
  7. Workflows : comment le runtime authentifie ses livraisons vers /.well-known/workflow/v1/flow et /step (préalable de C7).
  8. 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.
  9. experimental_startInstallation : ouvert à l'équipe ou non (demande à Vercel).
  10. getConnectorMetadata : confirmer qu'il n'est pas facturé comme une demande de jeton.
  11. Le format de BLOB_STORE_ID que pose Vercel (avec ou sans préfixe store_, que le SDK retire).

Résolution de la critique

#SévéritéRéponseOù
1hauteappliquéc) étape 4 oidc (règle des préséances silencieuses) ; messages invalid ; PLAN C3, F2
2hauteappliquéCorrections, 3 ; alternative 9 ; risques ; PLAN §0.3, F0, F2
3hauteappliqué, sauf la conclusion « une app suffit » (rejet 1)f) moindre privilège ; alternatives 13 et 14 ; SourceRepo ; PLAN F5a, F5b, C7
4hauteappliquéFaits fournisseurs ; table de rotation ; Publisher ; PLAN F8, C5, rotation
5hauteappliquéc) mode et développement local ; f) environnements ; alternative 16 ; PLAN F3, F6, C3
6hauteappliqué (nouvelle étape C3b)Décision ; d) OIDC de GitHub Actions ; f) runner ; alternative 15 ; PLAN C3b, F12
7hauteappliqué (params.runner plutôt qu'une colonne)a) RenderRunner ; d) Sandbox, Workflows, Flags ; f) Sandbox ; PLAN C7
8moyenneappliquéa) BlobStore ; c) règle des préséances silencieuses ; PLAN C3
9moyenneappliquéc) le garde ; forbidden ; PLAN C1, C2, C5, C6
10moyenneappliqué sous une autre forme (rejet 2)règle 5 des ports ; table de rotation ; PLAN C2, C4, rotation
11moyenneappliquéa) KeyValue et RateLimiter ; PLAN C2
12moyenneappliquéb) portée de la promesse ; a) Voice ; PLAN C3
13moyenneappliquée) une seule section « Connecteurs » ; PLAN C4
14moyenneappliquée) phrases vendues ; b) ; PLAN C5
15moyenneappliqué, source changée par le constat 23risques ; PLAN C3 (headers.ts lit le registre)
16moyenneappliquéc) gestes que les connecteurs rallument ; PLAN C3
17moyenneappliquéc) étape 2 ; b) capacités d'atelier ; PLAN C1, C4
18moyenneappliqué et étendu (rejet 4)c) secours ; alternative 17 ; risques ; PLAN C2, F1, runbook
19moyenneappliquéc) étape 1 ; PLAN §1, C2, C3
20moyenneappliquéd) Workflows ; PLAN C8
21basseappliquée) ?liaison= ; PLAN C4
22basseappliquéd) Image Optimization
23basseappliquéb) config.storeId, blobLegacy ; table de rotation ; PLAN C3
24basseappliquéb) détection des secrets ; f) gardes ; PLAN C1, C2
25basseappliquéa) règle 3, SourceRepo, PlatformBridge ; PLAN C6
26basseappliquéb) unicité par fournisseur ; PLAN C5
27basseappliquéc) cache des jetons ; a) vidéo ; f) ids et jetons
28basseappliquéc) mode d'échec, coût ; a) RenderRunner sans configured() ; PLAN C4, C6
29basseappliqué, sans le type dans le motif (rejet 5)c) plafond de budget ; PLAN C3
30basseappliquéCe que Vercel fournit (uid, déclencheurs, installation, jeton RW) ; en-tête ; e) « Installer »
31basseappliquéc) CLI ; f) moindre privilège ; PLAN F4
32basseappliqué ; les hôtes publics sont inventoriés, pas une capacité (rejet 3)d) KMS, sources publiques ; PLAN C6, C7
33basseappliquéen-tête ; PLAN (comptes, STUDIO_MAIL_FROM en C2, F0, F10, C1)

Constats rejetés

  1. 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? }), et getTokenResponse envoie les paramètres de l'appelant tels quels (token.js, corps de POST /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 omettre authorizationDetails et 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.
  2. 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éma create_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éma update_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.
  3. 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.ts de 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.
  4. 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é.
  5. Constat 29, ajouter quota_for_entity_exceeded aux motifs de solde. Ce type ne traverse pas l'AI SDK 7 : le fournisseur Gateway transforme un type inconnu en GatewayInternalServerError qui ne garde que le message et le statut (@ai-sdk/gateway 4.0.94, dist/index.js, branche default du switch (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é.