L’application web
Layout, groupes de routes, proxy, en-têtes de sécurité, environnement typé et internationalisation de l’application Orbit.
8. L’application web
-
L'interface appartient au studio (émancipée par
studio/15: plus de manifeste de vendoring ni de garde d'octets). Layout racine : polices Geist (next/font/google, servies depuis l'origine),<html class="dark">sans bascule ninext-themes, providers (le tenant, leToastersombre, le toast de session terminée), Vercel Web Analytics et Speed Insights (scripts servis en/_vercel/*, injectés par le bundle porteur du nonce : la CSPstrict-dynamicles laisse passer sans exception, et le proxy ne garde pas/_vercel). Les couleurs sont des tokens--studio-*(src/styles/tokens.css) ; seul le stage ([data-stage-ground],.bg-canvas-dots) est blanc. LesNEXT_PUBLIC_*de l'ancienne application d'origine font échouer le build s'ils sont posés (assertNoPlatformPublicEnv, appelé parnext.config.ts). -
Les pages authentifiées vivent sous le groupe
(dashboard)(layout : porterequireMember, écran « Conçu pour un ordinateur » sous 1 024 px, le cadreAppFrame: la marque, « Votre avis », le menu compte) :/brain,/studio/*,/academy,/developers/{api,mcp},/costs,/members,/settings./authet/onboardingsont sous(minimal)./est l'accueil marketing public d'Orbit (app/page.tsx)./demosert le prototype interne du Brain (apps/web/internal/), aux seulsPlatformAdmin(404 pour tout autre) ;/studioouvre la galerie et le composer sans produit,/studio/<produit>et/studio/libreun périmètre, un produit prévu ou inconnu répond 404. La section ouverte et le rendu ouvert sont dans l'URL (?s=,&item=, API History, sans navigation). Le registre des pages et de leurs permissions estsrc/features/studio/nav/routes.ts. Aucune ancienne URL n'est redirigée : elle rend la 404 sombre. -
L'hôte du cockpit (
StudioHost) est bâti parsrc/cockpit/host.ts, appelé parfeatures/studio/open-studio.ts: périmètre (scope.ts, le registre des produits tient lieu de sélecteur de marque), gestes (gestures.ts), prix du composer au coût fournisseur (costs.ts), galerie tirée du journalStudioGeneration(gallery.ts), cartes système (system-cards.ts: les coûts pour tous, les membres avecmembers.manage, puis un lien par autre produit ; calculées pour l'hôte, aucune page du cockpit ne les affiche aujourd'hui). Le moteur du cockpit (engine.ts) se déduit du contexte de génération de la phase 3 (generationContext) : un périmètre produit quand le membre tientgenerateet que fournisseurs et stockage répondent ; sinon il se lit seulement, et le bouton Générer dit « Lecture seule » (rôle sansgenerate) ou « Génération indisponible » (déploiement sans fournisseur ni stockage). La Composition (mode Motion) et la Bibliothèque suivent les rendus configurés ; « Poser une voix » suit une voix configurée. Le composer s'ouvre sur l'onglet Image (règle déclarée : le cockpit d'origine ouvrait sur Vidéo, prévu ici) ; les onglets Vidéo, Nova et Remplacer sont rendus inertes avec « Prévu », la ligne GPT-2 aussi. « Réutiliser » sur une carte que le lecteur ne peut pas refaire (une vidéo, un rendu de Composition sans le geste) rouvre le prompt et les images sur l'onglet Image, jamais sur un onglet prévu (règle déclarée surprompt-composer.tsx). La ligne Avatar du rail est éteinte (règle déclarée surdata.tsx,DISABLED_ROWS) : son rendu part d'une photo, et les fichiers de référence n'ont pas encore de moteur ; le signet du composer, dont les éléments enregistrés sont les visages de cette section, part avec elle. Rallumer les deux, c'est retirer"avatar"deDISABLED_ROWSquand la photo aura son moteur. -
Les deux cales d'actions du cockpit (
src/components/studio/{actions,ops-actions}.ts) ne sont plus des modules"use server"(G2-07) : leurs mutations sont les actions serveur desrc/cockpit/{studio-actions,ops-studio-actions}.ts, leurs lectures unGET /api/cockpit/<nom>(src/cockpit/reads.ts, derrièrerequireMemberApi). Les crédits affichés suivent chaque débit, côté client :TenantProvidertient le solde en état React, amorcé par l'instantané serveur seulement ; chaque geste qui retient ou débite rend le solde du membre, que la cale annonce (src/cockpit/credits-channel.ts), et le menu du compte le montre aussitôt, sansrouter.refresh()ni rechargement. -
Les tableaux lus dans le cockpit (
src/cockpit/boards.ts) : Cockpit créatif (mon solde, l'équipe, l'historique), QC créative, Concepts, et pour le périmètre ouvert le tableau de production et le tableau de bord système. Chaque montant et chaque livraison est filtré sur le lecteur : ses propres lignes et sommes, celles de l'équipe seulement aveccosts.view-team. Un bloc retenu par cette règle est compté dans la note de bas du tableau. Avatars enregistrés (src/cockpit/avatars.ts, servis mais sans section tant que la ligne Avatar est éteinte) : seulement depuis un rendu terminé du même produit, fait par le membre (le fondateur : par n'importe qui), sans photo source. L'espace libre n'en garde aucun : le rendu d'avatar y est refusé avant toute retenue, puisque son enregistrement le serait. Hors production,/api/dev/blob/<chemin>sert les fichiers du stockage local du moteur (local-blob:) au membre qui peut voir leur rendu (les routes/api/dev/*sont desroute.dev.ts, compilées sousnext devseulement :pageExtensionsdenext.config.ts). « Votre avis » poste vers/api/feedback, qui écrit au fondateur (FOUNDER_EMAIL), cinq par minute. -
Les pages de gestes du studio :
/costs(les coûts : mon solde, l'équipe aveccosts.view-team, l'historique ; mois en cours ou précédent,?period=),/members(la liste, l'invitation et les rôles ; la fiche d'un membre sur?member=<id>) et/settings(le compte : e-mail, rôle, permissions, échéance, déconnexion). Composées du kit d'administration (AdminPage,AdminSection,AdminStat,AdminDataTable,AdminFormShell,ConfirmActionDialog). Chaque geste renvoie la vue suivante de la page, posée en état client : aucune page ne se recharge. -
Pages publiques :
/(accueil marketing) et/auth(Server Component pour les métadonnées, formulaire client). Routes publiques :/api/auth/*(NextAuth,send-otp,sign-out),/api/health, la découverte OAuth et les portes développeur (section 3.2). Les liens légaux de/authvont à la documentationhttps://docs.easyconnector.app(/legal/*, un 307 pour une requête de document, un 204 pour une requête du routeur,src/lib/legal-pages.ts) ; c'est la seule redirection denext.config.ts. -
Aucun appel de fournisseur depuis une page. La couche serveur de la génération reste celle de la phase 3 : les actions typées de
src/generation/actions.ts(des mutations seulement, la liste est dans Contrat serveur du cockpit ; les lectures sont des routes GET), qui relisent le membre, passent par le ledger et rendent des données simples (argent en texte décimal USD, dates ISO, refus aveccodeet phrase française). Les deux cales d'actions du cockpit (src/components/studio/{actions,production-actions}.ts) y sont branchées parsrc/cockpit/produce.ts, sur le même service : image (Rapide sur le modèle flash, Haute qualité, Logo, Picto et chaque angle du Pack produit sur le modèle Pro, la consigne du modèle empilée par lestackPrompt, les sept formats de la pastille), boucle GIF du Pack produit (les images du lecteur, de ce périmètre), voix (le modèle, les curseurs, la voix par défautELEVENLABS_VOICE_ID: le moteur ne liste pas de voix, la liste le dit), suppression (les fichiers partent, la ligne reste), rendu d'avatar, Composition (les règles de copie d'abord, portées du cockpit d'origine, puis le rendu de gabarit) et Bibliothèque. La Fabrique (production par lots) est retirée. Chaque appel porte une clé d'idempotence neuve. Restent refusés avant tout stockage ou débit, avec leur phrase : la vidéo (onglets prévus), la retouche (Upscale et Variation absents), les fichiers de référence (photo d'avatar comprise), GPT Image, les compétences, règles et connecteurs (listes vides). Un refus du ledger porte le codecredits, que le toast du cockpit transforme en lien vers la page des coûts.POST /api/dev/generate(hors production) expose le même service à la suite E2E. -
Les fournisseurs enregistrés dans le cockpit. Sous
ORBIT_PROVIDERS=fixtures(jamais en production), le cockpit lit les enregistrements du moteur depuis leur dossier (le paquet d'une action serveur réécrit leurimport.meta.urlen chemin d'actif navigateur) et enregistre aussi le dispatch d'un rendu de gabarit (src/cockpit/recorded-dispatch.ts) : GitHub n'est pas appelé, la ligne, la réserve et le lien des runs sont réels, et comme aucun runner ne rappelle, le balayage finit la ligneno_callbacket rend la réserve. -
Les formulaires des membres sont ceux du kit d'administration porté du code d'origine (
AdminFormShell: react-hook-form et zod, donc JavaScript requis). Leurs actions serveur (src/members/actions.ts) rendent leur résultat ({ ok, code, message }), jamais une redirection ni une exception : un refus levé serait masqué en production.src/cockpit/member-toast.tsen fait le toast du cockpit (succès, erreur, ou avertissement quand l'invitation est créée mais que l'email n'est pas parti), puis le kit rafraîchit la page. Seule exception, la session coupée sous la page :requireMemberredirige l'action vers/api/auth/sign-out?reason=…, Next navigue ET rejette la promesse avec son erreurNEXT_REDIRECT.memberGesturela reconnaît et termine le geste sans toast (le kit aurait affiché « NEXT_REDIRECT » au-dessus du toast « session terminée » de/auth). Il le termine, sans le laisser en attente : React ne valide la navigation du routeur qu'une fois toutes les transitions liées réglées, et la page resterait affichée. -
Le contrat de la couche serveur est Contrat serveur du cockpit : les mutations sont les actions typées de
src/generation/actions.ts(generateImage,editImage,composeProductImage,generateVideo,generateVoiceover,generateLipsync,packLoopGif,generatePicto,generateAvatar,renderTemplate,deleteRender, et sans débituploadReference,confirmReferenceUpload,saveAvatar,deleteAvatar,purgeProductBlobs) ; les lectures sont des routes GET (/api/generation/context,/api/generations,/api/generations/[id],/api/voice-desk,/api/avatars), jamais des actions, parce que Next exécute les actions une à une et déconseille d'y lire des données (G2-07,actions-are-mutations.test.ts). Le cockpit, lui, lit par ses propres chargeurs serveur (src/cockpit/*). Le contrat est clos (lot 10) : chaque ligne de la table nomme une de ces actions ou une de ces routes, ou dit « phase 4 » ou « pont B1 », et les gardes du contrat StudioHost lisent la table pour le vérifier. -
Traces (lot 10, G2-20).
src/instrumentation.tsappelleregisterOTel({ serviceName: "orbit-studio" })(@vercel/otel) : les spans de Next (route, rendu,fetch) partent vers le collecteur de Vercel en production, vers un point OTLP en local (OTEL_EXPORTER_OTLP_ENDPOINT), nulle part sinon. Puis, sous Node,registerStudioTelemetry()(@orbit/engine/telemetry) enregistre l'intégration OpenTelemetry de l'AI SDK 7, sortie deaivers@ai-sdk/otel. Sous les spans de Next, le moteur écrit les siens (traceur@orbit/engine) :studio.generationpar génération (membre, produit, sorte, puis id et issue :delivered,running,replayedou le code du refus),studio.provider.<appel>par appel de fournisseur, fixtures comprises (tracedProviders, posé dansdeps.ts), etstudio.video.settle,studio.render.launch,studio.render.callback,studio.sweep,studio.retention(leurs comptes en attributs). Les appels de modèle de langage (images Nano Banana, picto) portenttelemetry: { functionId: <capacité>, includeRuntimeContext }etruntimeContext: { generationId, memberId }(les noms de la v7 :experimental_telemetryetmetadatasont remplacés) ; l'intégration écrit alors, selon les conventions GenAI,invoke_agent <modèle>(gen_ai.agent.name= la capacité,ai.settings.context.*= les ids),step 1etchat <modèle>avec les jetons.generateImageet le départ d'une vidéo n'acceptent pas d'option de télémétrie en 7.0 : le span du fournisseur les couvre. Jamais un prompt, une référence, un fichier ou un secret en attribut (recordInputsetrecordOutputscoupés). Le contexte de trace n'est propagé qu'aux URL du déploiement (défaut de@vercel/otel), jamais à la Gateway, ElevenLabs, GitHub ou Blob. -
Journal structuré (
src/lib/log.ts) : une ligne JSON par événement (level,eventen nom pointé,time,traceId,spanId, puis des champs plats), jamais une pile, un corps, un secret, un prompt ou un code. Les routes ne l'écrivent que par lui (log.test.tsrefuseconsole.*dans unroute.ts) : refus des portes machine (render.callback.rejected,video.webhook.rejected,cron.rejected), issue des webhooks, compte de chaque passage de cron (cron.sweep.done,cron.retention.done, enwarnquand il y a des erreurs), échecs de l'OTP. -
Tests : Vitest (unitaires, et intégration contre Postgres quand
DATABASE_URLest posée) ; Playwright pour les flux E2E-01 à E2E-04 et E2E-06 (apps/web/e2e,pnpm e2e), sousnext devcontre une basestudio_e2evidée et semée parglobal-setup.ts, les codes lus dans le journal du transport console.step5-production.spec.tsproduit depuis le composer avec chaque moteur branché, sur les fournisseurs enregistrés.src/cockpit/__tests__/catalogues.test.tsexige que les catalogues du composer (gabarits, modèles et réglages de voix, prix d'une image et d'une seconde de vidéo) égalent ceux du moteur : un prix qui bouge casse un test, pas une facture. -
GET /api/healthrenvoie{ ok, version, db, auth }.dbvautok,absent(pas deDATABASE_URL: pas une panne, statut 200) ouerror(ok: false, statut 503).authvautok,development(la constante hors production) oumissing: une production sansNEXTAUTH_SECRETrépondok: falseet 503, parce que toute route protégée répond alors elle-même 503 (le proxy lit le secret dans untryet répond{"error":{"code":"misconfigured"}}, jamais une pile d'appels). Avant le correctif (constat HEALTH-GREEN-WITHOUT-SECRET), la sonde restait verte pendant que chaque page rendait une erreur 500. -
En-têtes de sécurité (constat SEC-1,
lib/security/headers.ts) :next.config.tspose sur tous les chemins, fichiers statiques compris,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY, unePermissions-Policyfermée (caméra, micro, géolocalisation, paiement, USB...),Cross-Origin-Opener-Policy: same-originet, en production seulement,Strict-Transport-Security(deux ans, sous-domaines). Le proxy ajoute à chaque page uneContent-Security-Policyà nonce tiré par requête (politique documentée par Next :script-src 'self' 'nonce-…' 'strict-dynamic',frame-ancestors 'none',object-src 'none',base-urietform-actionlimités à l'origine ;'unsafe-eval'en développement seulement ;upgrade-insecure-requestsen HTTPS seulement ; styles en'unsafe-inline', car un nonce ne couvre pas les attributsstyleet un style ne s'exécute pas). Next pose le nonce sur ses propres scripts ; le layout racine lit la requête, donc toutes les pages sont rendues à la demande (une page pré-rendue n'aurait pas de nonce). Preuve surnext start:curl -sD - localhost:3000/authrenvoie les sept en-têtes, et chaque<script>de la page porte le nonce de l'en-tête. -
Env typé :
apps/web/src/env/schema.ts(pur, testé) etserver.ts(import "server-only"). Absent ou vide = défaut ; présent mais invalide = défaut et un avertissement qui nomme la clé, jamais la valeur. Un seul.env, à la racine :next.config.tsle charge, Prisma et le seed aussi.
i18n
Tout le produit est en anglais par défaut et en français en second : chaque
chaîne affichée existe dans les deux langues, le code et les commits sont en
anglais, les URL aussi, et la documentation (dépôt docs.easyconnector.app) reste en français.
Catalogue. apps/web/messages/en.json est la source de vérité et
apps/web/messages/fr.json la reflète clé pour clé (next-intl 4, envoyé au
navigateur par groupe de routes : src/i18n/client-namespaces.ts). Les
namespaces historiques (landing, studio, dashboard, auth, errors,
shared, patterns...) sont lus par useTranslations. Le namespace app
porte tout le reste (rôles, membres, équipe, génération, cockpit, Brain,
facturation, coûts, paramètres, e-mails, développeurs, Academy...) et est
typé : AppCopy = typeof en.app (src/i18n/copy.ts). Le code serveur lit
copyFor(me.locale), les pages getCopy() (copy-server.ts), les composants
client useCopy() (copy-client.ts) et les modules client sans React
clientCopy() (client-copy.tsx, alimenté par ClientCopyBridge). Une clé
inconnue est une erreur de typage, jamais un repli. Les clés ne contiennent pas
de point (next-intl les lit comme un chemin) : une permission storage.purge
s'écrit storage-purge (permissionLabel).
Quelle langue. La landing reste pilotée par son URL (/ en anglais, /fr en
français : src/proxy.ts réécrit /fr vers / et transmet la langue dans
x-orbit-locale, effacé sur toute autre route donc non falsifiable). Pour
l'application, dans l'ordre (src/i18n/request.ts, src/auth/member-context.ts) :
- la préférence enregistrée du membre,
StudioMember.locale(texte nullable, CHECKenoufr, migration committée), relue en base à chaque requête comme le reste du membre, jamais lue dans le JWT ; - l'en-tête
Accept-Languagedu navigateur si son premier choix commence parfr(src/i18n/negotiate.tsne fait que le lire : le résultat esten,frou rien, et il ne peut acheter qu'une langue) ; - l'anglais.
<html lang> suit la langue. Aucune URL ne change et aucun cookie ne choisit une
langue. Le sélecteur est une action serveur (setLocaleAction, un membre ne
règle que sa propre langue, valeur validée avant la base, sans audit : une
préférence d'affichage n'est pas un geste gouverné) exposée dans les paramètres
et dans le menu du compte (components/shell/language-switch.tsx). Un
visiteur anonyme n'a pas de sélecteur : son navigateur décide.
E-mails. La langue du destinataire (StudioMember.locale), sinon celle de
la requête qui les déclenche, sinon l'anglais (src/lib/mail/locale.ts).
API, MCP et moteur. Les erreurs de /api/v1 et /api/mcp, les messages du
moteur (packages/engine) et des connecteurs sont en anglais seulement,
orientés machine. Les libellés des gabarits de Space du moteur sont l'anglais du
catalogue (app.brain.templates), égalité vérifiée par messages.test.ts ; le
français des gabarits créatifs (services/creative/templates.json, en anglais)
est la surcouche app.creativeTemplates. Le back-office apps/admin est en
anglais.
Gardes.
src/i18n/messages.test.ts: mêmes namespaces et mêmes clés dans les deux langues, mêmes arguments ICU ({x},select, balises riches), aucun message vide, aucun tiret cadratin ni demi-cadratin, aucune clé avec un point.src/i18n/copy.test.ts: casse de phrase sur le catalogueapp.src/i18n/hardcoded-copy.test.ts: aucune chaîne visible en dur danssrc/appetsrc/components(liste blanche explicite : termes de marque, unités, et deux fichiers justifiés,global-error.tsxqui n'a pas de fournisseur et le harnais de parité du Brain).e2e/i18n.spec.ts: première visite avecAccept-Language: fr, bascule dans les paramètres, persistance au rechargement et d'une session à l'autre, langue enregistrée plus forte que le navigateur, e-mail dans la langue du membre, API et MCP en anglais.- Le harnais de parité du Brain (
pnpm --filter @orbit/web parity,e2e/parity-next.spec.ts) garde ses goldens français : le spec enregistrefrcomme langue du membre de test juste après la connexion (UPDATE "StudioMember" SET locale = 'fr'), ce que fait le sélecteur.
Texte persisté : les libellés écrits en base au moment d'un geste (les constats d'une relecture de copy, le nom du workspace créé à la première connexion) le sont dans la langue de la requête et ne sont pas retraduits ensuite.