Revue d'application · Lecture seule

Oneflex Transformation Hub

Flexstone Phase 1 — Partnership, Governance & Organization Design
Revue du 18 septembre 2026 · Deal cible : 55–75 k€ · Livraison lundi

B+
Solide et sécurisé pour une livraison client. Architecture propre, sécurité au-dessus de la moyenne. Deux points à verrouiller avant lundi (rate-limiting + PWA installable).

Volet 1 — Diagnostic du site déployé

Ce qu'un visiteur non connecté perçoit

Le site est accessible et fonctionnel — pas de blocage anti-bot, pas de page vide. Un visiteur qui tape l'URL voit une page soignée :

Page/auth/signed-out (redirigé depuis /)
Titre"This workspace is private"
Message"Access is by personal invitation. If you are part of the programme team, ask your programme contact for an invitation link."
LogosFlexstone Partners × ComColors (branding soigné)
DesignSplit-screen : visuel architecture + carte d'invitation. Responsive, dark navy.

OK Pas de problème de première impression. La page de renvoi est volontairement publique et bien conçue. C'est un message clair et professionnel, pas une page vide ni une erreur.

Pourquoi un assistant IA externe n'a "rien récupéré"

Ce n'est pas un blocage anti-bot ni une SPA cassée. L'explication :

  1. Tout le contenu est derrière une session. Le Worker (Cloudflare) authentifie chaque requête. Sans cookie de session valide, tout renvoie 302 → /auth/signed-out (pages) ou 401 (API).
  2. Le curl initial ne suit pas les redirects par défaut. curl https://.../ retourne un 302 Location: /auth/signed-out avec un body vide. Sans -L, on ne voit rien.
  3. Les routes "invisibles" renvoyent toutes la même page publique. /health, /api, /manifest.json, /robots.txt — tout est intercepté par le Worker et renvoie le HTML "private workspace". Un crawler qui teste des routes génériques ne trouve donc jamais de contenu distinct.
  4. robots.txt = Disallow: / + x-robots-tag: noindex, nofollow sur toutes les réponses. Les crawlers bien éduqués s'arrêtent immédiatement.

Vérifié par curl -L : la racine renvoie bien la page "private workspace" en 200. Le site fonctionne parfaitement pour un humain qui a un lien d'invitation.

Stack edgeCloudflare Workers + D1 (SQLite edge) + custom domain
Workerflexstone-phase1-hub (workers.dev + custom domain)
D1flexstone-hub (région WEUR)
HTTPSHSTS 1 an, TLS via Cloudflare
En-têtes sécuCSP stricte, X-Frame-Options DENY, nosniff, no-referrer, Permissions-Policy, COOP same-origin
Cacheprivate, no-cache (API : no-store) — pas de cache partagé

Stack & architecture

FrontendPreact 10.29 + htm 3 (JSX-like sans build) · SPA vanilla, zero framework build step
Backend1 seul fichier : src/worker.js (1 019 lignes) — Cloudflare Worker
Base de donnéesD1 (SQLite edge) · 5 migrations · pas d'ORM, SQL préparé direct
HébergementCloudflare Workers · custom domain oneflex-transformation-hub.com
BuildAucun. JS natif en modules ES. npm run deploy = gates + stamp + wrangler deploy
Tests0 tests unitaires · Gates : scan confidentialité + lint vues + syntaxe check

Schéma de flux

Browser (Preact SPA)
   │  fetch() same-origin, cookie __Host-hub_session
   ▼
Cloudflare Worker (src/worker.js)
   │  1. isPublicPath() ? → servir les assets publics
   │  2. authenticate() → session cookie → SHA-256 → D1 sessions
   │  3. membership check → D1 members table
   │  4. /api/* → handler API (CRUD items, comments, settings, content, admin)
   │  5. sinon → servir le fichier statique depuis public/
   ▼
D1 (SQLite) — items, comments, users, sessions, invites, members,
              settings, activity, content_docs, content_revisions

Parcours d'authentification

Modèle : invitation par lien unique

Pas de sign-in classique (email/mot de passe). L'admin crée un membre → le Worker génère un lien d'invitation à usage unique (token 256 bits, stocké en SHA-256, valide 14 jours) → le membre ouvre le lien → session cookie __Host-hub_session (45 jours, sliding, HttpOnly, Secure, SameSite=Lax).

ÉtapeFichierDétail
Invitationworker.js L831-840createInvite() — token aléatoire 32 octets, hash SHA-256 en DB, URL /auth/invite#token
Rachatworker.js L162-194redeemInvite() — POST /auth/redeem, race-safe (UPDATE … WHERE used_at IS NULL), crée la session
Sessionworker.js L144-160readSession() — cookie → SHA-256 → lookup D1, sliding expiry (renouvelée si >6h)
Authworker.js L85-129authenticate() — 3 sources : dev (localhost), Cloudflare Access JWT (optionnel, désactivé), session cookie. Puis check membership.
Logoutworker.js L196-205POST /auth/logout — révoque la session en DB, efface le cookie
Page signed-outpublic/auth/signed-out.htmlPage publique, message "This workspace is private"
Page invitepublic/auth/invite.html + auth.jsLit le token du fragment URL (jamais loggé), POST /auth/redeem
Gestion membresworker.js L781-881Admin-only : lister, inviter, révoquer, changer rôle. Protection "last admin" et "cannot lock yourself out"
Client SPApublic/app/lib/api.jsChaque write porte x-hub-request: 1 (anti-CSRF). credentials: same-origin

Option Cloudflare Access (JWT RS256) : le code est présent et complet (vérification de signature, issuer, audience, exp/nbf, cache JWKS 10 min) mais ACCESS_TEAM_DOMAIN et ACCESS_AUD sont vides dans wrangler.jsoncdésactivé en production. L'auth repose 100 % sur le système d'invitations interne.

Sécurité

AspectÉtat
Secrets en DBOK Tokens d'invitation et IDs de session stockés en SHA-256, jamais en clair
Cookie sessionOK __Host- prefix, HttpOnly, Secure, SameSite=Lax, Path=/
CSRFOK Header x-hub-request: 1 requis sur tous les writes + vérification Origin si présent
XSSOK CSP stricte (default-src 'self', pas de unsafe-eval), Preact échappe par défaut, cleanText() strippe les contrôle chars
SQL injectionOK 100 % de requêtes préparées avec .bind(), aucun string concat
Rate limitingABSENT Aucun throttle sur /auth/redeem, /api/*, ni sur l'invitation. Voir Risque #1.
ConfidentialitéOK Double filtre : scan-sensitive.mjs (gate deploy, ~50 patterns bloquants) + SENSITIVE_PATTERNS côté Worker (demande de confirmation à l'éditeur)
En-têtes HTTPOK CSP, HSTS, X-Frame-Options DENY, nosniff, no-referrer, Permissions-Policy, COOP, x-robots-tag
Validation inputOK LIMITS constants (title 300, text 120, data 40k, comment 5k), regex email, whitelist de kind/status/org
ConcurrenceOK Optimistic locking (version sur items), race-safe sur redeem (UPDATE WHERE used_at IS NULL)
BackupOK /api/export (admin) + D1 Time Travel 30 jours

Routes & pages principales

RouteVueRôle
/OverviewTableau de bord : where we are, activité récente
/programmeProgrammePlan Phase 1 — calendrier, semaines, jalons
/deliverablesDeliverablesKanban des livrables (drag & drop entre colonnes de statut)
/interviewsInterviewsConversations partenaires (à planifier, planifiées, tenues)
/decision-mapDecision mapCarte des décisions (proposées, décidées, remplacées)
/actionsActionsActions & décisions opérationnelles
/rhythmRhythmGouvernance & rythme (notes hebdo partagées, annonces)
/ways-of-workingWaysManières de travailler
/contextContextContexte partagé (documents éditables par admin)
/roadmapRoadmapRoadmap 24 mois
/peoplePeopleÉquipe + accès workspace (admin : inviter, révoquer)
/libraryLibraryMéthodes & glossaire
/activityActivityFeed d'activité (créations, modifications, commentaires)
/content-editorEditorÉditeur de contenu (admin uniquement) avec révisions
/p/:slugCustomPages personnalisées définies dans le contenu (dynamique)

5 sections de navigation : Where we are · The plan · The work · What we are designing · Background. Command palette (⌘K) pour chercher dans les pages, livrables et documents.

Tests & déploiement

Tests unitairesAucun — pas de framework de test, pas de suite
Gates deployscan-sensitive.mjs (confidentialité) + lint-views.mjs (structure vues) + node --check (syntaxe tous les JS)
État actuelGates passants — "Confidentiality scan: clean" · "views lint: clean"
Déploiementnpm run deploy → gates → stamp-version.mjs (cache-busting) → wrangler deploy
EnvironnementsProduction uniquement (pas de staging). wrangler dev --local pour le dev local.
CI/CDAucun — pas de GitHub Actions, pas de pipeline automatisé. Deploy manuel via CLI.
GitRepo local, pas de remote configuré. Dernier commit : nav restructure (5 sections).

5 risques avant la livraison client (lundi)

1. Pas de rate limiting sur l'auth et l'API

Élevé

/auth/redeem n'a aucun throttle. Un attaquant peut tenter des dizaines de milliers de tokens par seconde (le hash SHA-256 rend le brute-force théorique impossible, mais le volume de requêtes peut saturer le Worker et générer du bruit en logs). Idem pour l'API en général.

Ajouter un rate limit simple par IP sur /auth/redeem (ex. 10 tentatives / 5 min) et un limite globale sur l'API. Cloudflare Workers a un r2 ou kv pour compter, ou un header cf-connecting-ip + un Map en mémoire (multi-instance mais suffisant pour ce volume).

2. Aucune batterie de tests — régression silencieuse possible

Moyen

Le Worker fait 1 019 lignes de logique critique (auth, CRUD, admin, content) sans aucun test. Les gates vérifient la syntaxe et la confidentialité, pas le comportement. Un bug introduit ne sera détecté que par un utilisateur.

Avant lundi : au minimum 5 tests manuels structurés (invitation → login → CRUD item → commentaire → logout) documentés dans un fichier docs/smoke-test.md. À terme : vitest + miniflare pour les tests du Worker.

3. PWA non installable (manifest.json renvoyé par le Worker)

Moyen

manifest.webmanifest n'est pas dans la liste des chemins publics. Un visiteur qui tente d'installer l'app sur son téléphone reçoit le HTML "private workspace" au lieu du manifest JSON → l'installation PWA échoue silencieusement. Le <link rel="manifest"> dans index.html pointe vers une route protégée.

Soit ajouter /manifest.webmanifest à PUBLIC_EXACT dans le Worker (le manifest ne contient pas de données sensibles), soit retirer le <link rel="manifest"> si la PWA n'est pas un objectif. Actuellement c'est un demi-état qui peut confuser.

4. Single point of failure — un seul admin, pas de remote git

Moyen

Le repo n'a pas de remote Git configuré (pas de GitHub/GitLab). Tout le code vit sur cette machine. L'unique admin est timothee.jullien@comcolors.com. Si la machine est perdue ou l'admin indisponible, le workspace est bloqué (le mécanisme d'urgence existe dans DEPLOY.md mais nécessite un accès CLI à l'account Cloudflare).

Pusher le repo sur un remote Git (GitHub privé) avant la livraison. Ajouter un second admin (ex. un contact Flexstone ou Howard) via l'interface People → Workspace access.

5. Pas de staging — les changements vont direct en production

Moyen

Il n'y a qu'un seul environnement. Chaque wrangler deploy met à jour la prod. Les migrations D1 s'appliquent directement sur la base de production. Un bug de migration ou un deploy cassé impacte immédiatement tous les utilisateurs connectés.

Pour la livraison, c'est acceptable (l'équipe est petite, le volume faible). À noter dans le runbook : en cas de problème, wrangler rollback + D1 Time Travel (30 jours). Documenter la procédure de rollback dans DEPLOY.md.

Mes choix (5 lignes)

  1. J'ai diagnostiqué le site par curl (pas de browser) — le site est accessible, le "rien récupéré" de l'IA externe vient du fait que tout est derrière session + le curl initial ne suit pas les redirects.
  2. Je n'ai modifié aucun fichier du projet — revue 100 % lecture seule.
  3. J'ai exécuté npm run gates pour vérifier l'état du CI (passant) sans déclencher de deploy.
  4. Rapport publié sur le domaine du projet (route publique) pour que tu aies un lien stable — aucun secret n'apparaît dans le HTML.
  5. J'ai noté les points à corriger mais je n'ai rien corrigé (contrainte lecture seule). Les 5 risques sont ordonnés par impact sur la livraison lundi.