Aller au contenu
Mocky/Docs v0.2
Architecture

Vue d'ensemble de l'architecture

32 min de lecture

1. Où vit chaque chose#

Le fait le plus structurant du projet :

Le pipeline de génération tourne dans le navigateur.

Ce qui est faitTourne dansFichiers
Sélection des capacitésNavigateursrc/lib/capabilities/select.ts
Planificateur (facultatif)Navigateursrc/lib/plan.ts
Génération, édition, réparationNavigateursrc/lib/generate.ts
Passe de qualité : vérifier un écran, puis le corrigerNavigateur ; détection sur le serveursrc/lib/quality.ts, polish.ts, server/muse/quality/
Audit SEO / accessibilité : règles de balisage, puis correctionNavigateur ; la moitié jugée sur le serveursrc/lib/audit/, server/muse/quality/audit-judge.js
Orchestration du pipelineNavigateur (React)src/components/ProjectView.tsx
Pont DESIGN.md (préambule, jetons, fiche, export)Navigateursrc/lib/design.ts, designTokens.ts, designSpec.ts, export/
Une direction lue comme une fiche de spécification, et modifiée comme telleNavigateursrc/lib/designSpec.ts, src/components/DesignSpecSheet.tsx
Quelle direction gouverne une générationNavigateursrc/lib/direction.ts
Affichage isolé de l'aperçuNavigateursrc/components/Preview.tsx, lib/capabilities/prelude.ts
PersistancelocalStorage, recopié sur le serveur si connectésrc/lib/project.ts, sync.ts, merge.ts
Comptes, SSO, synchronisation, proxy modèleServeurserver/index.js, server/provider-proxy.js
Utilisation par compte : projets, écrans, disqueServeurserver/usage.js
Cadrage du canevas infini (tout afficher, focus, dernier écran)Navigateursrc/lib/framing.ts
Trouver et remplacer les images d'un écran (AST, sans modèle)Navigateursrc/lib/screenImages.ts, src/components/ScreenImagesDialog.tsx
Trouver et remplacer les séquences de défilement d'un écran (AST, sans modèle)Navigateursrc/lib/screenSequences.ts — un couple, base et frames, réécrits ensemble
Muse : MCP, récupération de pages, distillation, dossierServeurserver/muse/
Images, vidéos, bibliothèquesServeurserver/images/, server/videos/
Export vidéo : schéma, l'unique appel de modèle, file, magasinServeurserver/video/ — au singulier ; server/videos/ est la bibliothèque de clips
Export vidéo : le rendu proprement ditUn service Docker séparé et facultatifworker/video/ — Remotion, plus three pour les blocs 3D et le monde continu, lottie-web pour les icônes animées et soixante typographies livrées. Absent tant que --profile video-export n'a pas été construit

Le back-end est volontairement petit : des fichiers JSON dans server/data/, aucune base de données, aucune dépendance native. Les dépendances d'exécution sont express, cookie-parser, @modelcontextprotocol/sdk et zod pour Muse, et impeccable pour la passe de qualité.

Les écritures sont atomiques : on écrit dans un fichier temporaire, puis on le renomme. Un plantage en cours d'écriture ne laisse donc jamais de fichier à moitié écrit.

Cette posture « pas de base de données, pas de dépendance native » est un invariant de fait, et l'image node:22-slim repose dessus. impeccable ne l'affaiblit pas : ses six dépendances d'exécution sont toutes en JavaScript pur, et le Puppeteer qu'il déclare est optionnel, pour un moteur d'analyse d'URL que Mocky n'appelle jamais. Voir les invariants pour savoir pourquoi ce drapeau vit dans le Dockerfile et pas dans un .npmrc.


2. Le registre de capacités#

Une capacité est ce que Mocky injecte dans l'aperçu pour qu'un composant généré puisse utiliser du code qu'il n'a pas écrit lui-même. Il y en a trois sortes, déclarées dans src/lib/capabilities/types.ts :

JavaScript
export type CapabilityKind = 'cdn-script' | 'cdn-css' | 'snippet-pack'
  • snippet-pack — du JSX ordinaire, gardé sous forme de chaîne, ajouté devant le code généré avant Babel.transform. C'est la sorte dominante.
  • cdn-css — une balise .
  • cdn-script — une balise .
  • Le prélude est encodé de la même façon, quand il y en a un.
  • Babel.transform(prélude + '\n' + source, { presets: [['react', { runtime: 'classic' }]] }) s'exécute à l'intérieur de l'iframe.
  • Le résultat est exécuté via une URL blob:, ce qui donne de vraies positions d'erreur.
  • Le composant est monté à l'intérieur d'une frontière d'erreur React.
  • Cette frontière est nécessaire parce que createRoot affiche de façon asynchrone. Une erreur d'affichage survient après le retour du try/catch synchrone, donc elle s'échapperait vers window.onerror sous la forme d'un « Script error. » sans détail — le module vient d'une origine blob:null.

    La frontière l'attrape avec le vrai message et la pile de composants, et la transmet au parent. Cela alimente à la fois la boîte d'erreur et fixComponent. Elle ne se déclenche que sur de vraies erreurs, c'est l'invariant I5.

    L'erreur React #130 est reformulée avant d'être signalée, parce que son message minifié n'apprend rien :

    Element type is invalid (React #130) : un composant ou une icône que vous avez affiché est indéfini — probablement un nom absent ou mal orthographié.

    Le pont d'interaction#

    Un petit script à l'intérieur de l'iframe parle au parent par postMessage.

    MessageSensÀ quoi il sert
    mode pickParent → iframeSurligner l'élément survolé. Au clic, renvoyer un sélecteur CSS, le texte visible, la balise et la chaîne de classes
    mode demoParent → iframeAvec une liste de paires {selector, target}, un clic demande au parent de naviguer
    okIframe → parentLe composant s'est monté correctement
    errorIframe → parentUne erreur de compilation ou d'exécution, avec son vrai message
    sizeIframe → parentLa hauteur du contenu affiché

    En mode pick, la sélection est exacte pour Modifier, et remonte jusqu'au plus proche ancêtre cliquable pour Lien.

    L'identité vient de la fenêtre qui envoie, jamais d'un champ à l'intérieur du message. frameId est écrit en clair dans chaque srcDoc : un aperçu pourrait donc lire l'identifiant d'un autre dans le DOM et forger des messages en son nom. Et e.origin vaut l'inutile chaîne "null" pour toute iframe isolée.

    JavaScript
    if (e.source !== iframeRef.current?.contentWindow) return   // côté parentif (e.source !== window.parent) return                      // côté iframe

    De façon symétrique, une iframe ne peut signaler un clic que si le mode pick est réellement actif, et ne peut demander une navigation que si elle a des liens de démo. Sans ces contrôles, un composant affiché pourrait piloter l'interface du parent à volonté.

    Empêcher la maquette de quitter son propre document#

    Une iframe isolée a toujours le droit de se naviguer elle-même. Un , un formulaire soumis, un location.assign() : n'importe lequel fait abandonner le srcDoc à l'iframe, qui charge alors l'index.html de Mocky. Comme elle n'a pas d'origine propre, tous les modules de l'application échouent ensuite en CORS. Écran blanc, console saturée, et l'écran qu'on venait de générer a disparu.

    Quatre gardes, en profondeur :

    1. window.open est neutralisé avant l'exécution de tout code généré. window.location n'est volontairement pas touché : c'est un accesseur non configurable, le redéfinir lève une exception et emporterait tout le pont.
    2. Un gestionnaire de clic en phase de capture annule tous les et , y compris les ancres internes. Un document srcdoc hérite de l'URL du parent comme base, donc #pricing se résout en http://localhost:8787/#pricing, qui est un autre document. Le défilement que l'ancre devait produire est fait à la main avec scrollIntoView, pour que les ancres internes se comportent quand même comme des ancres.
    3. Les soumissions de formulaire sont annulées. Un
      sans action poste vers l'URL du document, c'est-à-dire vers la page de Mocky.
    4. Le parent compte les événements load de l'iframe. Le premier est le srcDoc ; tout autre signifie qu'elle est partie ailleurs. Le parent réattribue alors srcdoc, un attribut qu'il possède quelle que soit l'origine de l'iframe, et affiche « les liens sont inertes » pendant trois secondes.

    Le rythme#

    Le srcDoc est reconstruit après 500 ms sans nouveau morceau, pour qu'un flux de jetons ne reconstruise pas l'iframe à chaque caractère.

    Un délai de 20 secondes évite d'attendre indéfiniment si aucun message n'arrive.

    Pendant la génération, les erreurs sont ignorées : le code est incomplet par construction. Une erreur dont le code source a changé depuis la construction du srcDoc est écartée comme périmée.

    La capture : une exception qui n'en est plus une#

    src/lib/capture.ts montait une iframe de même origine, et ce document expliquait longuement pourquoi c'était inévitable. Ça ne l'est plus.

    La cause était html2canvas : il doit lire le document qu'il photographie, et il le clone dans une iframe à lui — or une isolation sans allow-same-origin donne à chaque descendant une nouvelle origine vide, si bien que l'iframe ne pouvait plus lire son propre clone. Elle échouait avec « Blocked a frame with origin null from accessing a cross-origin frame », aussi bien sur le chemin par défaut qu'avec foreignObjectRendering.

    Mocky capture désormais avec snapdom, qui sérialise le sous-arbre en un SVG où le style calculé de chaque nœud est inliné, puis rastérise le tout via une URL data:. Rien dans ce chemin ne franchit de frontière d'origine : l'iframe de capture ne porte plus que allow-scripts, exactement comme un aperçu. Pendant la seconde que dure une capture, le code du modèle ne s'exécute donc plus avec l'origine de Mocky — il ne peut plus lire la clé API dans localStorage, ni atteindre window.parent.

    Mesuré en origine opaque avant d'engager le changement : la capture aboutit, toDataURL() ne lève rien (le canvas n'est pas teinté), les utilitaires injectés par Tailwind sont respectés, et rgb(var(--token) / 0.5) se compose au pixel exact. 113 ms par défaut, contre 111 ms pour html2canvas avec l'origine ouverte.

    Deux conséquences à connaître :

    • connect-src a dû s'ouvrir à cette origine. snapdom inline une image en récupérant ses octets ; sous connect-src 'none', chaque requête était bloquée et chaque image devenait un aplat gris. /api/images/:hash répond donc aussi avec Access-Control-Allow-Origin: * — cette route est déjà publique par conception, un joker n'y expose rien. Toutes les autres directives sortantes restent fermées : il n'y a toujours nulle part où envoyer quoi que ce soit.
    • Un onglet masqué diffère la capture. snapdom rastérise en attendant img.decode(), qui ne se résout jamais dans un document que le navigateur ne compose pas : en arrière-plan, la même capture prenait 39 s là où html2canvas en prenait 1. La coquille attend maintenant visibilitychange au lieu d'épuiser son chien de garde.

    7. Un seul dialecte pour tous les modèles#

    Mocky parle toujours le dialecte Ollama en interne : POST /api/chat, avec options, num_ctx, num_predict, format, et une diffusion en NDJSON.

    Un modèle qui réfléchit au lieu de répondre#

    Changer pour un modèle de raisonnement donnait génération après génération sans rien dedans, et une seule phrase : « le modèle a renvoyé une réponse vide ». Trois choses différentes se cachent derrière, et le traducteur jetait la preuve des trois — il lisait content et rien d'autre.

    Un modèle de raisonnement répond sur deux canaux, reasoning (ou reasoning_content, ou un reasoning_details structuré selon le fournisseur) et content. Mocky veut le second et ne doit jamais prendre le premier pour lui : un composant extrait d'une chaîne de pensée n'est pas un composant. La réflexion est donc COMPTÉE et jamais transmise, un refus que le fournisseur a mis dans le corps d'un 200 (pas de crédit pour ce modèle, une limite de débit, un amont qui décline) est cité plutôt qu'avalé, et le navigateur transforme le fait en une phrase actionnable — emptyAnswer dans generate.ts.

    Puis la cause elle-même. Une exécution réelle a dépensé 110 220 caractères en réflexion sans écrire une ligne de code, alors que max_tokens: 16384 ne bornait rien : ce fournisseur ne compte pas le raisonnement dedans. reasoning est le paramètre d'OpenRouter prévu pour ça — il normalise un budget de réflexion entre les fournisseurs qu'il place derrière lui et l'ignore pour les modèles qui ne raisonnent pas — donc buildUpstream ajoute reasoning: { effort: 'low' } quand l'hôte visé est OpenRouter, et nulle part ailleurs : api.openai.com répond 400 à une clé de corps qu'il ne connaît pas, et un paramètre envoyé à l'aveugle casserait tous les utilisateurs d'OpenAI, Groq et Together pour en réparer un d'OpenRouter. « low » plutôt que « rien », parce que les modèles qui valent la peine réfléchissent — ce qui est refusé, c'est la réflexion sans fin.

    server/text/dialect.js traduit vers et depuis les API compatibles OpenAI : forme de la requête, response_format, pièces jointes de vision en image_url, et conversion SSE vers NDJSON. La génération, le planificateur et Muse sont donc indépendants du fournisseur, sans deuxième chemin de code.

    Le proxy vit à deux endroits qui partagent le même module : un middleware Vite en développement, et app.use('/__provider', …) dans Express en production.

    Trois protections s'appliquent.

    Une liste de sous-chemins autorisés. /api/chat et /api/tags, rien d'autre.

    Une protection contre le SSRF. assertSafeTargetResolved() n'accepte que http et https, refuse localhost, les plages privées, les adresses de lien local et 169.254.169.254 — puis résout le nom de domaine et revérifie chaque adresse renvoyée. Sans cette deuxième étape, un nom de domaine contrôlé par l'appelant (evil.test pointant sur 127.0.0.1) passait les tests de chaîne sans encombre. Les formes IPv6 correspondant à de l'IPv4 sont couvertes explicitement, dans leurs deux écritures : ::ffff:127.0.0.1 et son jumeau hexadécimal ::ffff:7f00:1.

    Un corps de requête borné. readRawBody() s'arrête à 25 Mo. Sans borne, il accumulait ce que le client envoyait puis appelait Buffer.concat dans l'écouteur end. Un corps dépassant buffer.constants.MAX_LENGTH levait alors en dehors de toute chaîne de promesses et, sans gestionnaire uncaughtException, emportait tout le serveur.

    Une cible configurée par un administrateur contourne volontairement la protection SSRF. Pointer vers un modèle local — Ollama, LM Studio ou vLLM sur 127.0.0.1 — est un montage prévu, et seul un administrateur peut le régler. La protection reste entière pour toute URL venue d'un navigateur.

    Quand un fournisseur d'instance est configuré, /__provider exige une session. La requête dépense les crédits de l'hébergeur, donc elle doit appartenir à quelqu'un. Sans fournisseur d'instance, l'appelant fournit sa propre clé, et le mode « votre clé ne quitte pas votre navigateur » est préservé.


    8. La persistance#

    Dans le navigateur#

    Clé localStorageContenu
    mocky.projects.v1Les projets, avec écrans, positions et liens — et la direction de design propre à chaque projet
    mocky.design.v1Le DESIGN.md global et son interrupteur — le repli d'un projet qui n'a pas de direction à lui
    mocky.settings.v1Fournisseur, URL de base, clé d'API, planificateur activé ou non
    mocky.muse.v1La configuration Muse : URL d'inspiration, mode image, vidéo, média épinglé
    mocky.animations.v1auto, on ou off

    Sur le serveur#

    Un fichier par utilisateur, server/data/data-.json, qui contient projects et design sérialisés plus un horodatage updatedAt. Deux routes : GET /api/data et PUT /api/data.

    La synchronisation est différée et observable. scheduleSync() marque l'état comme modifié, et un état idle | syncing | failed est diffusé aux abonnés, pour qu'un échec soit visible dans l'interface. Auparavant, une synchronisation qui abandonnait après trente secondes de tentatives n'était visible de personne, pas même dans la console.

    La réconciliation compare updatedAt des deux côtés au lieu de supposer que le serveur est plus récent, ce qui écrasait le travail local. La fusion dans src/lib/merge.ts utilise des marqueurs de suppression avec une durée de vie, pour qu'une suppression faite sur un appareil ne revienne pas depuis un autre.

    Le magasin serveur#

    Chemin sous server/data/Contenu
    users.jsonLes comptes : sel et empreinte scrypt, rôle, dashySub
    sessions.jsonJeton → { u: userId, t: horodatage, c: ouverture, ua: "Firefox 131 · Windows", ip } — l'appareil est un résumé, jamais l'en-tête. Seule une empreinte du jeton atteint le tableau de bord (D2)
    config.json{ allowRegistration, maintenance, announcement } — le deuxième vaut { on, message, since } et voyage avec une migration, ce qui explique qu'une instance importée démarre en lecture seule ; le troisième est le bandeau de l'administrateur, { id, message, tone, createdAt, startsAt, expiresAt } — ses dates dans le texte sont des instants {{datetime:ISO}}, que chaque navigateur écrit dans son propre fuseau
    audit.jsonlAdministration → Journal d'audit : un objet JSON par ligne, les 2 000 derniers. Connexions, comptes, sessions, réglages (NOMS des champs seulement), maintenance, annonces, migrations. Le seul magasin du tableau de bord sur disque (D4)
    sso-jti.jsonLes identifiants de jeton SSO déjà consommés, purgés après 10 minutes
    data-.jsonLes projets et le design d'un utilisateur
    avatars/Un fichier par compte ayant envoyé une photo. Compté en bytes.avatar dans le rapport d'utilisation
    text-config.jsonLes fournisseurs de texte configurés par l'administrateur, secrets compris
    images-config.jsonLes fournisseurs d'images et les réglages de la vidéo au défilement, secrets compris
    video-config.jsonLes réglages de l'export vidéo : l'interrupteur maître, le mode d'accès et sa liste, l'URL du worker, et la clé de licence Remotion en clair — d'où le mode 0600, et d'où publicView() qui la transforme en booléen hasLicenseKey avant que quoi que ce soit quitte le serveur
    muse-cache.jsonLes distillations, 7 jours de durée de vie, texte uniquement
    image-library.jsonLes métadonnées de la bibliothèque d'images — dont owners, les identifiants des comptes qui ont déposé chaque fichier, borné à 20
    image-library/Les octets des images
    video-library/Les séquences : clip, images et affiche. Leurs métadonnées portent owners sous la même borne
    video-exports.jsonLes films exportés : octets, conteneur, nombre de scènes, durée — et owners sous la même borne. Jamais le montage, qui porte le texte incrusté écrit par quelqu'un
    video-exports/.mp4|.webmLe film terminé, entier. Un répertoire distinct de video-library/ à dessein : celui-là contient des séquences de défilement, découpées en images par ffmpeg, et tout ce qui lit son list() attend des images qu'un film n'a pas
    video-jobs.jsonLe journal de la file de rendu : les 50 derniers jobs terminés, plus ceux en cours. Un job trouvé en cours au démarrage passe en erreur, jamais repris
    .migration/L'état d'une migration sur le NOUVEAU serveur : staging/ (les fichiers déjà récupérés), staged.json (leurs empreintes, pour qu'un passage reprenne), report.json (le dernier import, pour Vérifier l'intégrité) et previous-/ (ce que contenait le dossier avant le remplacement — déplacé, jamais supprimé). Jamais listé lui-même dans un manifeste

    Les fichiers contenant des secrets sont écrits en mode 0600. Le 0644 par défaut les laissait lisibles par tous les autres comptes de la machine.

    La borne sur owners est celle que portent déjà tags et projects juste à côté, pour la même raison : ces index sont ré-sérialisés en entier à chaque écriture, donc rien de ce qu'ils contiennent n'a le droit de grandir sans plafond.


    9. Les routes HTTP#

    Méthode et routeAuthentificationÀ quoi ça sert
    GET /api/health—dataWritable et frontendBuilt ; 503 avec un detail qui nomme le problème
    GET /api/config—Inscription ouverte ?, mode installation, SSO, modèle d'instance (sans secret)
    POST /api/register, /api/loginlimitéLe premier compte devient administrateur
    POST /api/logout, GET /api/mecookie/api/me répond 200 { user: null }, pas 401
    POST /api/presencesessionLe battement d'un onglet — ou son au revoir, en balise. 204 ; ne compte pas comme une activité, permis pendant la maintenance (D5)
    POST /api/account/passwordsession, limitéRévoque toutes les sessions et en délivre une neuve
    GET /sso/dashy/callbacklimitéVérifie le jeton HS256, trouve ou crée le compte
    GET/PUT /api/admin/config, /users, …/password, DELETE /users/:idadminGestion de l'instance et des utilisateurs
    GET /api/admin/usageadminProjets et disque par compte. Route à part parce qu'elle analyse le blob projets de chaque utilisateur et parcourt un répertoire par séquence vidéo ; répond 200 avec un champ error plutôt qu'un statut d'erreur, pour qu'un rapport en échec ne casse jamais l'écran Admin
    GET/PUT /api/admin/text/config, POST /api/admin/text/testadminLe test envoie une vraie requête
    GET/PUT /api/admin/images/config, POST /api/admin/images/testadminLe test génère une vraie image, non conservée
    POST /api/text/visionsessionSonde la vision du modèle. Passe par la protection SSRF
    GET/PUT /api/datasessionLes projets et le design de l'utilisateur
    GET /api/mcp/statussessionL'état de chaque serveur MCP déclaré
    POST /api/muse/dossiersessionDiscover → Distill → Dossier
    POST /api/muse/auditsessionLa moitié jugée du rapport SEO / accessibilité. 400 uniquement si code manque ; 200 avec une liste vide et une notice quand il n'y a pas de modèle
    POST /api/muse/qualitysession{ code, hasDirection, critique } en entrée, un rapport en sortie. 400 uniquement si code manque ; 200 même sans modèle configuré — voir plus bas
    POST /api/images/generate, /uploadsession, 30/minGénérer est le verbe coûteux
    GET /api/images/library, /library.zip, POST /:hash/favorite, DELETE /:hashsessionGestion de la bibliothèque
    POST /api/images/:hash/confirmsession, propriétaireRetire la marque pending d'une image produite par le parcours de variantes. À sens unique et idempotent : il n'y a pas de dé-confirmation, parce que « personne n'a encore regardé ceci » est un fait passé
    GET /api/images/:hashpublicVoir plus bas
    POST /api/videos/generate (6/min), /upload (20/min)sessionPlafonds différents : générer coûte de l'argent, importer coûte du disque
    GET /api/videos/library, /:hash/meta, DELETE /:hashsessionGestion des séquences
    GET /api/videos/:hash/poster.jpg, /:hash/f/:n.jpgpublicVoir plus bas
    GET /api/video/statussessionL'export vidéo — noter le singulier. Accès, état du worker, et les bornes du schéma que le panneau cite
    POST /api/video/compose (12/min)sessionLe seul appel modèle de la fonctionnalité. Il COMPOSE un film — un fond et un à huit blocs typés par scène, tirés d’énumérations fermées — ou remplit l’une des cinq compositions toutes faites quand un appelant la nomme, sur les images que l'utilisateur a déjà choisies : il choisit le film, il ne choisit jamais les images. Un imageId hors de la sélection est refusé, jamais substitué ; un document que le schéma rejette est refusé en entier plutôt que réparé ; une composition qui exige une image, demandée alors que rien n'est sélectionné, est refusée par une remarque qui nomme titles. Une sélection vide est donc une demande et non une erreur. Le theme voyage dans le corps et n'est attaché qu'après validation du document du modèle — un modèle qui écrit le sien est refusé. Répond 200 avec timeline: null et des remarques quand rien d'utilisable n'est revenu — une proposition qui n'a pas eu lieu n'est pas une requête ratée (Q1). 409 si la sélection contient encore une image que personne n'a confirmée : le même garde que /render, vérifié ici aussi pour qu'un rebut ne devienne pas la scène quatre
    POST /api/video/variants (6/min)sessionDe deux à six prises d'une image de la bibliothèque. Compté comme /api/videos/generate plutôt que comme /compose, parce que chaque variante est un appel au fournisseur. Répond derived : avec un profil d'image « edit » les images sortent de celle de l'utilisateur, sans lui ce sont des sœurs nées du même texte — et une interface qui montrerait les deux à l'identique mentirait
    POST /api/video/render (6/min)sessionValide le montage, puis met en file. 400 avec la liste des défauts, 404 en nommant les images absentes, 409 si la sélection contient encore une image que personne n'a confirmée, 507 si le volume est déjà plein
    GET /api/video/jobs/:idsession403, pas 404, sur le job d'un autre : un job porte le montage, et un montage porte son texte incrusté
    GET /api/video/:hashsessionLe film terminé. Jamais public — la propriété est vérifiée avant l'existence, donc un hash inconnu et celui d'un autre répondent pareil
    GET/PUT /api/admin/video/config, GET /api/admin/video/healthadminLa clé de licence sort en hasLicenseKey, un booléen
    GET /api/admin/dashboard/overview, /liveadminLe tableau de bord : une heure de mesures et d'événements, puis des Server-Sent Events toutes les 2 s. Le flux revérifie l'administrateur à chaque envoi et se termine par event: bye quand ce n'est plus vrai
    GET /api/admin/dashboard/sessions, DELETE …/sessions/:id, POST …/users/:id/signoutadminLes sessions, par empreinte de leur jeton (D2). Sa propre session est refusée (400) — se déconnecter à la place
    GET /api/admin/dashboard/audit, PUT/DELETE …/announcementadminLe journal d'audit, du plus récent au plus ancien, par groupe ; l'annonce, publiée par GET /api/config une fois son startsAt passé — le tableau de bord la voit scheduled avant
    GET/PUT /api/admin/maintenanceadminMode lecture seule. Actif, toute requête autre que GET d'un non-administrateur reçoit 503 { code: 'maintenance' }, sauf connexion et déconnexion — voir server/maintenance.js
    GET/POST/DELETE /api/admin/migration/sourceadmin ; POST redemande le mot de passeLe code d'appairage de l'ANCIEN serveur. Affiché une fois, gardé en mémoire seulement, 24 h
    GET /api/admin/migration/import, POST …/connect, …/pass, …/cancel, …/disconnect, …/verify, …/finalizeadmin ; finalize redemande le mot de passeLe côté du NOUVEAU serveur : vérifier, tirer, remplacer, redémarrer. connect interroge une URL saisie par l'administrateur — le quatrième contournement SSRF
    GET /api/migration/manifest, /api/migration/filesignature d'appairage, pas de sessionLes deux seules routes que l'ancien serveur expose au nouveau. 401 tant qu'aucun code n'est actif ; chaque corps est scellé en AES-256-GCM sous le code
    POST /api/admin/video/benchmarkadminRend trois films de référence via le worker, dans le créneau exclusif de la file, et donne ce que coûte chaque niveau de rendu sur cette machine. 409 tant qu’un rendu d’utilisateur tient le créneau
    ALL /__provider/api/chat, /api/tagssession si un modèle d'instance est configuréProxy et traduction de dialecte

    Pourquoi les octets d'images et d'images vidéo sont publics#

    C'est volontaire, et c'est nécessaire.

    Les iframes d'aperçu sont isolées sans allow-same-origin, donc elles n'ont pas d'origine propre et leurs requêtes de sous-ressources ne portent aucun cookie SameSite. Une route /:hash authentifiée viderait toutes les images de toutes les maquettes.

    Un ZIP exporté référence aussi ces URL depuis une machine sans session.

    L'URL est la clé d'accès : une empreinte SHA-256 de 64 caractères hexadécimaux, indevinable, et distribuée uniquement par une liste authentifiée. Le motif est strict — PUBLIC_IMAGE_PATH = /^\/[a-f0-9]{64}$/ — donc lister, générer et supprimer restent derrière une session.

    Cette garde est attachée aux sous-chemins que servent les routeurs, pas au montage /api. Montée sur /api, elle s'exécutait pour toutes les routes /api/* suivantes, ce qui plaçait en silence les octets publics derrière l'authentification.

    Pourquoi owners n'atteint jamais un navigateur#

    L'autre moitié de la même question. server/images/routes.js et server/videos/routes.js retirent tous deux owners de chaque listing avant qu'il ne quitte le serveur, et c'est une décision de confidentialité, pas de propreté.

    Les médiathèques sont à l'échelle de l'instance : tout utilisateur connecté liste toutes les images. Laissé dans la réponse, un compte ordinaire apprend son propre identifiant dans le meta de son premier envoi, soustrait ses images de la liste, et détient alors la bibliothèque globale partitionnée par auteur — qui a produit combien, et quels prompts vont ensemble. C'est exactement pour cela que publicUser(), dans server/index.js, omet id, et que seule GET /api/admin/users en publie un.

    Cela ne coûte rien à la fonctionnalité : rien sous src/ ne lit owners. Le rapport d'utilisation le consomme côté serveur, via collectUsage, qui lit directement l'objet bibliothèque.

    Pourquoi la vérification de qualité répond 200 sans modèle#

    La route est derrière une session comme tout le reste de Muse — app.use('/api/muse', requireUser) — et elle refuse une requête dans un seul cas : il n'y a pas de code à regarder, et c'est un 400. L'absence de modèle n'est pas ce cas-là.

    Un rapport a deux moitiés. Les règles déterministes n'ont besoin que de la source ; la moitié jugée a besoin d'un modèle. Les identifiants suivent exactement la route du dossier : un fournisseur configuré par l'administrateur l'emporte, sinon ce sont les en-têtes du navigateur, ceux-là mêmes que lit /__provider. Sans identifiants, la première moitié tourne quand même et la seconde se déclare indisponible : il y a donc une vraie réponse à rendre — les défauts trouvés, un audit qui dit quelles dimensions ont réellement été examinées, et une note qui nomme ce qui n'a pas tourné. Un 4xx dirait « cet écran n'a pas pu être vérifié », ce qui est faux, et le navigateur le remonterait comme un échec au-dessus d'un écran généré sans le moindre problème. Dégrader, jamais échouer — l'invariant Q1.


    10. L'export#

    src/lib/export/project.ts assemble un projet Vite + React + Tailwind exécutable à partir des écrans, avec trois cibles.

    CibleContenu
    plainTailwind plus les packs d'interface de Mocky, copiés dans le projet
    shadcnCe qui précède, plus components.json, le cn() standard et le thème Tailwind de shadcn, pour que npx shadcn add … hérite de la marque via globals.css
    daisyuiTailwind plus le plugin daisyUI

    La réécriture de JSX vers ESM, dans export/rewrite.ts, passe par Babel, jamais par une expression régulière. Elle transforme d'abord le JSX en React.createElement, pour que chaque référence de composant devienne un identifiant ordinaire, puis interroge la portée.

    export/theme.ts transforme DESIGN.md en globals.css. Les expressions régulières y sont autorisées, parce qu'elles lisent de la prose Markdown, pas du code : c'est l'exception explicite de l'invariant I1.

    Le ZIP est écrit par src/lib/zip.ts, sans aucune dépendance : méthode « store » plus CRC32. Le même écrivain sert au « Tout télécharger » de la bibliothèque d'images et à npm run backup.

    Chaque pack qu'un écran peut importer, et aucun qu'il ne peut utiliser#

    rewrite.ts construit sa table d'imports depuis le REGISTRE — chaque export de pack devient @/components/ui/ — alors que uiFiles() était une liste écrite à la main de trois entrées. Quatre packs étaient donc importés et jamais livrés : animate, scene3d, scrollvideo, motionfilm. est sur presque tous les écrans générés, donc presque tous les exports échouaient sur un module absent. La liste est maintenant DÉRIVÉE de CAPABILITIES dans project.test.ts, ce qui empêche le prochain pack de répéter la chose — y compris un pack retiré, puisqu'un écran généré avant son retrait l'importe encore.

    Les packs sont livrés comme le JavaScript qu'ils sont. Ils ont été écrits pour un navigateur sans compilateur : var Icon = {} puis Icon.Home = …, un cn variadique qui lit arguments, window.THREE. Livrés en .tsx ils étaient typés, et le npm run build du projet exporté — c'est-à-dire tsc && vite build — échouait sur cinquante erreurs dans des fichiers que personne n'avait demandé à TypeScript de lire. Chaque pack est donc un .jsx avec un .d.ts écrit à la main à côté : chaque export est un React.FC, Icon un dictionnaire de ceux-là, cn une fonction variadique. TypeScript résout le module par la déclaration, vérifie les ÉCRANS — les fichiers qui méritent de l'être — et laisse le JavaScript vendorisé tranquille. Il a fallu installer et construire un export réel pour le découvrir, ce qui est la seule façon de le découvrir.

    three.js suit les écrans. lit window.THREE, donc le scene3d.jsx exporté importe la bibliothèque et l'y pose — mais seulement quand un écran nomme une scène (capabilitiesUsedBy, sur le code et non sur screen.caps). 600 Ko dans chaque export reviendrait à payer pour le catalogue et non pour les écrans, et sans elle le composant dessine le dégradé calme qu'il dessine dans un navigateur sans WebGL.

    Les images et les films restent ceux de Mocky. Un src="/api/images/…" ou "/api/video/…" généré ne résout que là où ce serveur est ; le README de l'export le dit plutôt que de faire semblant.

    Ce n'est pas Motion Ultra, avec lequel il ne partage que le mot. Celui-là transforme des images de la médiathèque en .mp4 sur un service Docker séparé et facultatif, et ne touche jamais à un écran — voir Motion Ultra.

    Les documents : PDF, .pptx et PNG#

    Un écran DOCUMENT (Screen.page renseigné) s’exporte par un chemin à part, src/lib/docExport/, entièrement dans le navigateur. Tout part d’un contrat, src/lib/pageFormats.ts : les tailles de page en px CSS et en points PDF, et les attributs DOM qu’écrit le kit de pages (data-mocky-doc, data-mocky-page, data-mocky-field). Le kit (capabilities/snippets/Document.ts, le pack document) donne à chaque sa taille exacte et signale le nombre de pages et tout dépassement ; l’export relit les mêmes attributs, donc un attribut renommé d’un côté l’est des deux.

    render.ts ouvre le document hors écran dans la coquille de capture (openSettledDocument : images chargées d’emblée, polices et images stabilisées, mise en page à la hauteur du canevas), puis le parcourt page par page. measure.ts lit les mots, les champs et les liens de chaque page avec leurs boîtes — en sautant ce qui est visuellement caché, et en signalant de combien le texte dépasse de chaque bord et quels mots. Chaque page est ensuite rastérisée par le moteur du navigateur lui-même à travers un foreignObject SVG (nativeRaster.ts), avec html2canvas en secours pour une page qu’il ne sait pas dessiner ou un navigateur qui refuse de la relire.

    SortieFabriquée parCe qu’elle garde
    PDFpdf.ts, avec pdf-libL’image de la page, une couche de texte invisible pour que le texte reste sélectionnable, et un vrai champ AcroForm pour chaque
    .pptxpptx.ts, OOXML écrit à la mainL’image de la page, texte retiré, et chaque texte en zone de texte modifiable par-dessus
    PNGraster.ts + zip.tsLa page telle que dessinée ; un format de réseau social exactement à ses pixels (échelle 1)

    La même mesure pilote Ajuster à la page (docExport/fit.ts) : le dépassement est mesuré avant, transformé en liste de constats dans les pixels de la page pour fitComponent, et mesuré à nouveau sur la réponse, qui n’est écrite que si elle tient ou s’en approche sur le même nombre de pages (fitVerdict). Une passe qui ne s’affiche pas est traitée comme une passe qui ne tient pas.


    11. Les tests#

    npm test exécute Vitest sur tout le dépôt. Cinq suites méritent d'être connues, parce qu'elles lisent ce qui est réellement livré, pas une abstraction.

    tests/preview-sandbox.test.js verrouille la sécurité de l'aperçu en lisant Preview.tsx et capture.ts : la valeur exacte de sandbox, l'absence de balise externe, les directives de sécurité, la garde de navigation, le comportement du mode « sans animation », et la validation du pont postMessage.

    Elle existe parce que le seul test qui appliquait l'invariant I3 regardait le registre, et ne voyait donc pas les balises