Application OrbitL’application web

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 ni next-themes, providers (le tenant, le Toaster sombre, 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 CSP strict-dynamic les 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. Les NEXT_PUBLIC_* de l'ancienne application d'origine font échouer le build s'ils sont posés (assertNoPlatformPublicEnv, appelé par next.config.ts).

  • Les pages authentifiées vivent sous le groupe (dashboard) (layout : porte requireMember, écran « Conçu pour un ordinateur » sous 1 024 px, le cadre AppFrame : la marque, « Votre avis », le menu compte) : /brain, /studio/*, /academy, /developers/{api,mcp}, /costs, /members, /settings. /auth et /onboarding sont sous (minimal). / est l'accueil marketing public d'Orbit (app/page.tsx). /demo sert le prototype interne du Brain (apps/web/internal/), aux seuls PlatformAdmin (404 pour tout autre) ; /studio ouvre la galerie et le composer sans produit, /studio/<produit> et /studio/libre un 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 est src/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 par src/cockpit/host.ts, appelé par features/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 journal StudioGeneration (gallery.ts), cartes système (system-cards.ts : les coûts pour tous, les membres avec members.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 tient generate et que fournisseurs et stockage répondent ; sinon il se lit seulement, et le bouton Générer dit « Lecture seule » (rôle sans generate) 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 sur prompt-composer.tsx). La ligne Avatar du rail est éteinte (règle déclarée sur data.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" de DISABLED_ROWS quand 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 de src/cockpit/{studio-actions,ops-studio-actions}.ts, leurs lectures un GET /api/cockpit/<nom> (src/cockpit/reads.ts, derrière requireMemberApi). Les crédits affichés suivent chaque débit, côté client : TenantProvider tient 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, sans router.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 avec costs.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 des route.dev.ts, compilées sous next dev seulement : pageExtensions de next.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 avec costs.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 /auth vont à la documentation https://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 de next.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 avec code et phrase française). Les deux cales d'actions du cockpit (src/components/studio/{actions,production-actions}.ts) y sont branchées par src/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 le stackPrompt, 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éfaut ELEVENLABS_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 code credits, 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 leur import.meta.url en 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 ligne no_callback et 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.ts en 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 : requireMember redirige l'action vers /api/auth/sign-out?reason=…, Next navigue ET rejette la promesse avec son erreur NEXT_REDIRECT. memberGesture la 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ébit uploadReference, 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.ts appelle registerOTel({ 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 de ai vers @ai-sdk/otel. Sous les spans de Next, le moteur écrit les siens (traceur @orbit/engine) : studio.generation par génération (membre, produit, sorte, puis id et issue : delivered, running, replayed ou le code du refus), studio.provider.<appel> par appel de fournisseur, fixtures comprises (tracedProviders, posé dans deps.ts), et studio.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) portent telemetry: { functionId: <capacité>, includeRuntimeContext } et runtimeContext: { generationId, memberId } (les noms de la v7 : experimental_telemetry et metadata sont 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 1 et chat <modèle> avec les jetons. generateImage et 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 (recordInputs et recordOutputs coupé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, event en 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.ts refuse console.* dans un route.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, en warn quand il y a des erreurs), échecs de l'OTP.

  • Tests : Vitest (unitaires, et intégration contre Postgres quand DATABASE_URL est posée) ; Playwright pour les flux E2E-01 à E2E-04 et E2E-06 (apps/web/e2e, pnpm e2e), sous next dev contre une base studio_e2e vidée et semée par global-setup.ts, les codes lus dans le journal du transport console. step5-production.spec.ts produit depuis le composer avec chaque moteur branché, sur les fournisseurs enregistrés. src/cockpit/__tests__/catalogues.test.ts exige 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/health renvoie { ok, version, db, auth }. db vaut ok, absent (pas de DATABASE_URL : pas une panne, statut 200) ou error (ok: false, statut 503). auth vaut ok, development (la constante hors production) ou missing : une production sans NEXTAUTH_SECRET répond ok: false et 503, parce que toute route protégée répond alors elle-même 503 (le proxy lit le secret dans un try et 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.ts pose sur tous les chemins, fichiers statiques compris, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, une Permissions-Policy fermée (caméra, micro, géolocalisation, paiement, USB...), Cross-Origin-Opener-Policy: same-origin et, en production seulement, Strict-Transport-Security (deux ans, sous-domaines). Le proxy ajoute à chaque page une Content-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-uri et form-action limités à l'origine ; 'unsafe-eval' en développement seulement ; upgrade-insecure-requests en HTTPS seulement ; styles en 'unsafe-inline', car un nonce ne couvre pas les attributs style et 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 sur next start : curl -sD - localhost:3000/auth renvoie 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é) et server.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.ts le 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) :

  1. la préférence enregistrée du membre, StudioMember.locale (texte nullable, CHECK en ou fr, migration committée), relue en base à chaque requête comme le reste du membre, jamais lue dans le JWT ;
  2. l'en-tête Accept-Language du navigateur si son premier choix commence par fr (src/i18n/negotiate.ts ne fait que le lire : le résultat est en, fr ou rien, et il ne peut acheter qu'une langue) ;
  3. 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 catalogue app.
  • src/i18n/hardcoded-copy.test.ts : aucune chaîne visible en dur dans src/app et src/components (liste blanche explicite : termes de marque, unités, et deux fichiers justifiés, global-error.tsx qui n'a pas de fournisseur et le harnais de parité du Brain).
  • e2e/i18n.spec.ts : première visite avec Accept-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 enregistre fr comme 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.