diff --git a/control-plane/api/.dockerignore b/control-plane/api/.dockerignore
new file mode 100644
index 0000000..67b1f42
--- /dev/null
+++ b/control-plane/api/.dockerignore
@@ -0,0 +1,6 @@
+.env
+.env.*
+*_test.go
+test/
+Dockerfile
+.dockerignore
diff --git a/control-plane/api/.env.example b/control-plane/api/.env.example
new file mode 100644
index 0000000..0cbe927
--- /dev/null
+++ b/control-plane/api/.env.example
@@ -0,0 +1,30 @@
+# === Omnex control-plane API — variables d'environnement ===
+# Copier en .env (jamais versionné) et compléter.
+
+# Obligatoire : secret de signature JWT (min 32 octets)
+OMNEX_JWT_SECRET=change-me-32-bytes-minimum-secret!!
+
+# Adresse d'écoute
+OMNEX_ADDR=:8080
+
+# "dev" | "prod" (en prod, OMNEX_DATABASE_URL et OMNEX_REDIS_URL deviennent obligatoires)
+OMNEX_ENV=dev
+
+# CORS : origines autorisées (vitrine + back-office), séparées par des virgules
+OMNEX_ALLOWED_ORIGINS=http://localhost:5173
+
+# PostgreSQL (GORM). Vide en dev => store mémoire volatil.
+# OMNEX_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex port=5432 sslmode=disable
+
+# Redis (sessions). Vide en dev => sessions en mémoire volatile.
+# OMNEX_REDIS_URL=redis://:omnexredis@localhost:6379/0
+
+# Seed de l'utilisateur admin de départ (optionnel) — nom d'utilisateur, pas email
+OMNEX_SEED_USERNAME=admin
+OMNEX_SEED_PASSWORD=change-me-strong-password
+
+# Domaine des démos déployées dans le cluster
+OMNEX_DEMO_DOMAIN=demo.omnex.app
+
+# Tests d'intégration GORM (optionnel) — base jetable
+# OMNEX_TEST_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex_test port=5432 sslmode=disable
diff --git a/control-plane/api/Dockerfile b/control-plane/api/Dockerfile
new file mode 100644
index 0000000..509ae8d
--- /dev/null
+++ b/control-plane/api/Dockerfile
@@ -0,0 +1,20 @@
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
+
+FROM alpine:3.20
+RUN apk add --no-cache ca-certificates wget curl \
+ && wget -q https://get.helm.sh/helm-v3.16.1-linux-amd64.tar.gz -O /tmp/helm.tar.gz \
+ && tar -xzf /tmp/helm.tar.gz -C /tmp \
+ && mv /tmp/linux-amd64/helm /usr/local/bin/helm \
+ && rm -rf /tmp/helm.tar.gz /tmp/linux-amd64 \
+ && adduser -D -u 10001 omnex
+COPY --from=build /out/api /api
+USER omnex
+EXPOSE 8080
+HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
+ CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
+ENTRYPOINT ["/api"]
diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go
new file mode 100644
index 0000000..0b7105e
--- /dev/null
+++ b/control-plane/api/cmd/api/main.go
@@ -0,0 +1,167 @@
+// Commande api : point d'entrée du control-plane Omnex.
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/redis/go-redis/v9"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+ "github.com/omnex/control-plane/api/internal/config"
+ "github.com/omnex/control-plane/api/internal/db"
+ "github.com/omnex/control-plane/api/internal/demos"
+ "github.com/omnex/control-plane/api/internal/k8s"
+ "github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/router"
+ "github.com/omnex/control-plane/api/internal/sav"
+ "github.com/omnex/control-plane/api/internal/session"
+ "github.com/omnex/control-plane/api/internal/sub"
+)
+
+// memUsers : store utilisateurs en mémoire (fallback dev sans base).
+type memUsers map[string]auth.User
+
+func (m memUsers) ByUsername(username string) (auth.User, bool) {
+ u, ok := m[strings.ToLower(strings.TrimSpace(username))]
+ return u, ok
+}
+
+func (m memUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) {
+ key := strings.ToLower(strings.TrimSpace(username))
+ u := auth.User{ID: uuid.NewString(), Username: key, PasswordHash: passwordHash, Role: role}
+ m[key] = u
+ return u, nil
+}
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+
+ iss := auth.NewIssuer(cfg.JWTSecret, cfg.SessionTTL)
+
+ // Sessions : Redis en prod/dev configuré, mémoire en fallback dev.
+ var sessions session.Manager
+ if cfg.RedisURL != "" {
+ opt, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ log.Fatalf("redis url: %v", err)
+ }
+ rdb := redis.NewClient(opt)
+ if err := rdb.Ping(context.Background()).Err(); err != nil {
+ log.Fatalf("redis ping: %v", err)
+ }
+ sessions = session.NewRedisManager(rdb, cfg.SessionTTL)
+ log.Printf("sessions: Redis")
+ } else {
+ sessions = session.NewMemManager(cfg.SessionTTL)
+ log.Printf("sessions: mémoire (dev — définir OMNEX_REDIS_URL pour Redis)")
+ }
+
+ var userStore auth.UserStore
+ var leadStore leads.Store
+ var demoStore demos.Store
+ var demoPool demos.Pool
+ var codeStore sub.Store
+ var contactStore sav.Store
+ if cfg.DatabaseURL != "" {
+ gdb, err := db.Open(cfg.DatabaseURL)
+ if err != nil {
+ log.Fatalf("db: %v", err)
+ }
+ if err := db.AutoMigrate(gdb); err != nil {
+ log.Fatalf("migrate: %v", err)
+ }
+ if err := db.SeedExternalPool(gdb); err != nil {
+ log.Fatalf("seed pool: %v", err)
+ }
+ users := auth.NewGormStore(gdb)
+ seedAdmin(users)
+ userStore = users
+ codeStore = sub.NewGormStore(gdb)
+ leadStore = leads.NewGormStore(gdb)
+ demoStore = demos.NewGormStore(gdb)
+ demoPool = demos.NewGormPool(gdb)
+ contactStore = sav.NewGormStore(gdb)
+ log.Printf("persistance: PostgreSQL (GORM)")
+ } else {
+ userStore = seedMemUsers()
+ codeStore = sub.NewMemStore()
+ leadStore = leads.NewMemStore()
+ demoStore = demos.NewMemStore()
+ demoPool = demos.NewMemPool()
+ contactStore = sav.NewMemContactStore()
+ log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
+ }
+
+ // Créer le client Kubernetes
+ k8sClient, err := k8s.NewClient(&cfg)
+ if err != nil {
+ log.Fatalf("k8s client: %v", err)
+ }
+
+ helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, "/charts", "helm")
+ if err != nil {
+ log.Fatalf("helm provisioner: %v", err)
+ }
+
+ // Service de démos avec le provisioner Helm
+ demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
+ BaseDomain: cfg.DemoDomain,
+ TTL: demos.TTL,
+ MaxDemos: demos.MaxConcurrentDemos,
+ })
+
+ deps := router.Deps{
+ Cfg: cfg,
+ Issuer: iss,
+ Sessions: sessions,
+ AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
+ LeadsH: leads.NewHandler(leadStore),
+ DemosH: demos.NewHandler(demoSvc),
+ SubH: sub.NewHandler(codeStore),
+ ContactH: sav.NewHandler(contactStore),
+ }
+
+ r := router.New(deps)
+ log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env)
+ if err := r.Run(cfg.Addr); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// seedAdmin crée l'utilisateur admin de départ depuis l'env (idempotent).
+func seedAdmin(users *auth.GormStore) {
+ username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD")
+ if username == "" || pass == "" {
+ return
+ }
+ hash, err := auth.HashPassword(pass)
+ if err != nil {
+ log.Fatalf("seed: %v", err)
+ }
+ if err := users.EnsureUser(username, hash, auth.RoleAdmin); err != nil {
+ log.Fatalf("seed: %v", err)
+ }
+ log.Printf("utilisateur admin seedé: %s", username)
+}
+
+// seedMemUsers construit un store mémoire depuis l'env (dev uniquement).
+func seedMemUsers() memUsers {
+ users := memUsers{}
+ if username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD"); username != "" && pass != "" {
+ hash, err := auth.HashPassword(pass)
+ if err != nil {
+ log.Fatalf("seed: %v", err)
+ }
+ key := strings.ToLower(strings.TrimSpace(username))
+ users[key] = auth.User{ID: "seed-admin", Username: key, PasswordHash: hash, Role: auth.RoleAdmin}
+ log.Printf("utilisateur admin seedé (mémoire): %s", key)
+ }
+ return users
+}
diff --git a/control-plane/api/go.mod b/control-plane/api/go.mod
new file mode 100644
index 0000000..fca873d
--- /dev/null
+++ b/control-plane/api/go.mod
@@ -0,0 +1,80 @@
+module github.com/omnex/control-plane/api
+
+go 1.26.0
+
+require (
+ github.com/gin-gonic/gin v1.10.0
+ github.com/golang-jwt/jwt/v5 v5.2.1
+ github.com/google/uuid v1.6.0
+ github.com/redis/go-redis/v9 v9.7.0
+ go.yaml.in/yaml/v2 v2.4.3
+ golang.org/x/crypto v0.47.0
+ golang.org/x/time v0.14.0
+ gorm.io/driver/postgres v1.5.9
+ gorm.io/gorm v1.25.12
+ k8s.io/api v0.36.3
+ k8s.io/apimachinery v0.36.3
+ k8s.io/client-go v0.36.3
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.5.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/oauth2 v0.34.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/term v0.39.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
+ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
diff --git a/control-plane/api/go.sum b/control-plane/api/go.sum
new file mode 100644
index 0000000..8dfa045
--- /dev/null
+++ b/control-plane/api/go.sum
@@ -0,0 +1,195 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
+github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
+golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
+gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
+k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
+k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
+k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
+k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
+k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/control-plane/api/internal/auth/gormstore.go b/control-plane/api/internal/auth/gormstore.go
new file mode 100644
index 0000000..2ed88aa
--- /dev/null
+++ b/control-plane/api/internal/auth/gormstore.go
@@ -0,0 +1,56 @@
+package auth
+
+import (
+ "errors"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// GormStore : UserStore adossé à PostgreSQL via GORM.
+type GormStore struct {
+ db *gorm.DB
+}
+
+func NewGormStore(db *gorm.DB) *GormStore {
+ return &GormStore{db: db}
+}
+
+// ByUsername cherche un utilisateur (requête paramétrée : anti-injection).
+func (s *GormStore) ByUsername(username string) (User, bool) {
+ var u User
+ err := s.db.Where("username = ?", NormalizeUsername(username)).First(&u).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return User{}, false
+ }
+ if err != nil {
+ return User{}, false
+ }
+ return u, true
+}
+
+// Create insère un utilisateur (usage seed/admin), mot de passe déjà haché.
+func (s *GormStore) Create(username, passwordHash string, role Role) (User, error) {
+ u := User{
+ ID: uuid.NewString(),
+ Username: NormalizeUsername(username),
+ PasswordHash: passwordHash,
+ Role: role,
+ }
+ if role == RoleAdmin {
+ u.TypeAbo = "admin"
+ }
+ if err := s.db.Create(&u).Error; err != nil {
+ return User{}, err
+ }
+ return u, nil
+}
+
+// EnsureUser crée l'utilisateur s'il n'existe pas encore (idempotent, pour le seed).
+func (s *GormStore) EnsureUser(username, passwordHash string, role Role) error {
+ if _, found := s.ByUsername(username); found {
+ return nil
+ }
+ _, err := s.Create(username, passwordHash, role)
+ return err
+}
diff --git a/control-plane/api/internal/auth/handler.go b/control-plane/api/internal/auth/handler.go
new file mode 100644
index 0000000..027616c
--- /dev/null
+++ b/control-plane/api/internal/auth/handler.go
@@ -0,0 +1,176 @@
+package auth
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/session"
+)
+
+func (User) TableName() string { return "users" }
+
+type UserStore interface {
+ ByUsername(username string) (User, bool)
+ Create(username, passwordHash string, role Role) (User, error)
+}
+
+type Handler struct {
+ users UserStore
+ sessions session.Manager
+ iss *Issuer
+ secure bool // cookie Secure (activé en prod/HTTPS)
+}
+
+func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure bool) *Handler {
+ return &Handler{users: users, sessions: sessions, iss: iss, secure: secure}
+}
+
+type loginRequest struct {
+ Username string
+ Password string
+}
+
+// Login authentifie, ouvre une session Redis et pose le cookie httpOnly.
+// Réponse volontairement uniforme (pas d'énumération d'utilisateurs).
+func (h *Handler) Login(c *gin.Context) {
+ var req loginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+
+ user, found := h.users.ByUsername(req.Username)
+ if !found {
+ // On hache quand même une valeur bidon pour égaliser le temps de réponse.
+ _, _ = VerifyPassword(req.Password, dummyHash)
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
+ return
+ }
+
+ ok, err := VerifyPassword(req.Password, user.PasswordHash)
+ if err != nil || !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
+ return
+ }
+
+ token, err := h.startSession(c, user)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
+}
+
+// Me renvoie l'identité de la session courante (dont le rôle, pour le front).
+func (h *Handler) Me(c *gin.Context) {
+ p := PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+
+ user, found := h.users.ByUsername(p.Username)
+ if !found {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+
+ typeAbo := user.TypeAbo
+ if typeAbo == "premium" && time.Now().UTC().After(user.ExpiredAt) {
+ typeAbo = "demo" // expiré : on ne le renvoie plus comme premium
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "user_id": p.UserID,
+ "username": p.Username,
+ "role": p.Role,
+ "type_abonnement": typeAbo,
+ "expired_at": user.ExpiredAt,
+ })
+}
+
+type registerRequest struct {
+ Username string
+ Password string
+}
+
+// Register crée un compte commercial puis ouvre directement la session.
+func (h *Handler) Register(c *gin.Context) {
+ var req registerRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+
+ if _, exists := h.users.ByUsername(req.Username); exists {
+ c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
+ return
+ }
+
+ hash, err := HashPassword(req.Password)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ user, err := h.users.Create(req.Username, hash, RoleClient)
+ if err != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
+ return
+ }
+
+ token, err := h.startSession(c, user)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusCreated, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
+}
+
+// startSession ouvre une session Redis, signe le JWT et pose le cookie.
+func (h *Handler) startSession(c *gin.Context, user User) (string, error) {
+ sid, err := h.sessions.Create(c.Request.Context(), session.Session{
+ UserID: user.ID,
+ Role: string(user.Role),
+ })
+ if err != nil {
+ return "", err
+ }
+ token, err := h.iss.Issue(user.ID, user.Username, user.Role, sid)
+ if err != nil {
+ _ = h.sessions.Delete(c.Request.Context(), sid) // fail-secure
+ return "", err
+ }
+ h.setSessionCookie(c, token, int(h.sessions.TTL().Seconds()))
+ return token, nil
+}
+
+// Logout révoque la session Redis courante et efface le cookie.
+func (h *Handler) Logout(c *gin.Context) {
+ if p := PrincipalFrom(c); p != nil {
+ if err := h.sessions.Delete(c.Request.Context(), p.SessionID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ }
+ h.setSessionCookie(c, "", -1)
+ c.JSON(http.StatusOK, gin.H{"status": "déconnecté"})
+}
+
+func (h *Handler) setSessionCookie(c *gin.Context, value string, maxAge int) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: session.CookieName,
+ Value: value,
+ Path: "/",
+ MaxAge: maxAge,
+ HttpOnly: true,
+ Secure: h.secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// dummyHash : argon2id d'une valeur arbitraire, pour la mitigation de timing.
+const dummyHash = "=19=65536,t=1,p=4$" +
+ "3Vk0m5m8c1m6l0k9j8h7g6f5d4s3a2z1x0c9v8b7n6m"
diff --git a/control-plane/api/internal/auth/jwt.go b/control-plane/api/internal/auth/jwt.go
new file mode 100644
index 0000000..a872782
--- /dev/null
+++ b/control-plane/api/internal/auth/jwt.go
@@ -0,0 +1,63 @@
+package auth
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+var ErrInvalidToken = errors.New("token invalide")
+
+// Claims du token Omnex : porte le rôle et l'identifiant de session Redis (sid).
+// La signature (secret) authentifie le token ; le sid permet la révocation.
+type Claims struct {
+ Role Role `json:"role"`
+ Username string `json:"username"`
+ SessionID string `json:"sid"`
+ jwt.RegisteredClaims
+}
+
+// Issuer signe et vérifie des JWT HS256.
+type Issuer struct {
+ secret []byte
+ ttl time.Duration
+}
+
+func NewIssuer(secret []byte, ttl time.Duration) *Issuer {
+ return &Issuer{secret: secret, ttl: ttl}
+}
+
+// Issue crée un token pour un utilisateur, lié à une session Redis (sid).
+func (i *Issuer) Issue(userID string, username string, role Role, sid string) (string, error) {
+ now := time.Now()
+ claims := Claims{
+ Role: role,
+ SessionID: sid,
+ Username: username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: userID,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
+ NotBefore: jwt.NewNumericDate(now),
+ Issuer: "omnex",
+ },
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
+}
+
+// Verify valide la signature + l'expiration et renvoie les claims.
+func (i *Issuer) Verify(tokenStr string) (*Claims, error) {
+ claims := &Claims{}
+ _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
+ // Refuse tout algo autre que HS256 (protection alg-confusion).
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, ErrInvalidToken
+ }
+ return i.secret, nil
+ })
+ if err != nil {
+ return nil, ErrInvalidToken
+ }
+ return claims, nil
+}
diff --git a/control-plane/api/internal/auth/middleware.go b/control-plane/api/internal/auth/middleware.go
new file mode 100644
index 0000000..a24fead
--- /dev/null
+++ b/control-plane/api/internal/auth/middleware.go
@@ -0,0 +1,81 @@
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/session"
+)
+
+const ctxPrincipalKey = "omnex.principal"
+
+// Principal : identité authentifiée injectée dans le contexte de requête.
+type Principal struct {
+ UserID string
+ Username string
+ Role Role
+ SessionID string
+}
+
+// tokenFromRequest lit le JWT : cookie httpOnly en priorité, sinon Bearer.
+func tokenFromRequest(c *gin.Context) string {
+ if ck, err := c.Request.Cookie(session.CookieName); err == nil && ck.Value != "" {
+ return ck.Value
+ }
+ if t, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
+ return t
+ }
+ return ""
+}
+
+// RequireAuth valide le JWT (signature) PUIS l'existence de la session Redis.
+// Un logout supprime la session : le JWT devient alors invalide avant expiration.
+func RequireAuth(iss *Issuer, mgr session.Manager) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ raw := tokenFromRequest(c)
+ if raw == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentification requise"})
+ return
+ }
+ claims, err := iss.Verify(raw)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token invalide"})
+ return
+ }
+ _, found, err := mgr.Get(c.Request.Context(), claims.SessionID)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ if !found {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expirée ou révoquée"})
+ return
+ }
+ c.Set(ctxPrincipalKey, Principal{UserID: claims.Subject, Username: claims.Username, Role: claims.Role, SessionID: claims.SessionID})
+ c.Next()
+ }
+}
+
+// RequireRole impose un rôle minimal (admin > rôle demandé).
+func RequireRole(role Role) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ p := PrincipalFrom(c)
+ if p == nil || (p.Role != role && p.Role != RoleAdmin) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "accès refusé"})
+ return
+ }
+ c.Next()
+ }
+}
+
+// PrincipalFrom récupère l'identité injectée par RequireAuth.
+func PrincipalFrom(c *gin.Context) *Principal {
+ v, ok := c.Get(ctxPrincipalKey)
+ if !ok {
+ return nil
+ }
+ p, _ := v.(Principal)
+ return &p
+}
diff --git a/control-plane/api/internal/auth/models.go b/control-plane/api/internal/auth/models.go
new file mode 100644
index 0000000..6f769e6
--- /dev/null
+++ b/control-plane/api/internal/auth/models.go
@@ -0,0 +1,14 @@
+package auth
+
+import "time"
+
+type User struct {
+ ID string `gorm:"type:uuid;primaryKey" json:"id"`
+ Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
+ PasswordHash string `gorm:"not null" json:"-"`
+ Role Role `gorm:"size:20;not null" json:"role"`
+ TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
+ ExpiredAt time.Time `json:"expired_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/control-plane/api/internal/auth/password.go b/control-plane/api/internal/auth/password.go
new file mode 100644
index 0000000..cbe8408
--- /dev/null
+++ b/control-plane/api/internal/auth/password.go
@@ -0,0 +1,61 @@
+// Package auth : hachage de mots de passe (argon2id) et JWT.
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+
+ "golang.org/x/crypto/argon2"
+)
+
+// Paramètres argon2id (OWASP Password Storage Cheat Sheet).
+const (
+ argonTime = 1
+ argonMemory = 64 * 1024 // 64 MiB
+ argonThreads = 4
+ argonKeyLen = 32
+ argonSaltLen = 16
+)
+
+var ErrInvalidHash = errors.New("format de hash invalide")
+
+// HashPassword produit un hash argon2id encodé (format PHC).
+func HashPassword(password string) (string, error) {
+ salt := make([]byte, argonSaltLen)
+ if _, err := rand.Read(salt); err != nil {
+ return "", err
+ }
+ key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+ return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
+ argon2.Version, argonMemory, argonTime, argonThreads,
+ base64.RawStdEncoding.EncodeToString(salt),
+ base64.RawStdEncoding.EncodeToString(key),
+ ), nil
+}
+
+// VerifyPassword compare un mot de passe à un hash encodé, en temps constant.
+func VerifyPassword(password, encoded string) (bool, error) {
+ parts := strings.Split(encoded, "$")
+ if len(parts) != 6 || parts[1] != "argon2id" {
+ return false, ErrInvalidHash
+ }
+ var memory, time uint32
+ var threads uint8
+ if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
+ return false, ErrInvalidHash
+ }
+ salt, err := base64.RawStdEncoding.DecodeString(parts[4])
+ if err != nil {
+ return false, ErrInvalidHash
+ }
+ want, err := base64.RawStdEncoding.DecodeString(parts[5])
+ if err != nil {
+ return false, ErrInvalidHash
+ }
+ got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
+ return subtle.ConstantTimeCompare(got, want) == 1, nil
+}
diff --git a/control-plane/api/internal/auth/role.go b/control-plane/api/internal/auth/role.go
new file mode 100644
index 0000000..a9b0431
--- /dev/null
+++ b/control-plane/api/internal/auth/role.go
@@ -0,0 +1,9 @@
+package auth
+
+// Role : rôle applicatif d'un utilisateur du control-plane.
+type Role string
+
+const (
+ RoleClient Role = "client"
+ RoleAdmin Role = "admin"
+)
diff --git a/control-plane/api/internal/auth/utils.go b/control-plane/api/internal/auth/utils.go
new file mode 100644
index 0000000..e7881d4
--- /dev/null
+++ b/control-plane/api/internal/auth/utils.go
@@ -0,0 +1,33 @@
+package auth
+
+import (
+ "crypto/rand"
+ "strings"
+)
+
+const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+
+// GenerateCode génère un code de 16 caractères alphanumériques,
+// regroupés par 4 et séparés par des tirets (ex: "A3F9-K2M7-XQ4R-8ZT1").
+func GenerateCode() (string, error) {
+ const length = 16
+ const groupSize = 4
+
+ b := make([]byte, length)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+
+ var sb strings.Builder
+ for i := 0; i < length; i++ {
+ if i > 0 && i%groupSize == 0 {
+ sb.WriteByte('-')
+ }
+ sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)])
+ }
+ return sb.String(), nil
+}
+
+func NormalizeUsername(u string) string {
+ return strings.ToLower(strings.TrimSpace(u))
+}
diff --git a/control-plane/api/internal/config/config.go b/control-plane/api/internal/config/config.go
new file mode 100644
index 0000000..a86858d
--- /dev/null
+++ b/control-plane/api/internal/config/config.go
@@ -0,0 +1,81 @@
+// Package config charge la configuration depuis l'environnement.
+// Secure by design : aucun secret par défaut en dur, tout vient de l'env.
+package config
+
+import (
+ "errors"
+ "os"
+ "time"
+)
+
+type Config struct {
+ Addr string
+ JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens
+ SessionTTL time.Duration // durée de vie JWT + session Redis
+ AllowedOrigins []string // CORS (vitrine + back-office)
+ Env string // "dev" | "prod"
+ DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM)
+ RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
+ DemoDomain string // domaine des démos
+ RegistrationCode string // OMNEX_REGISTRATION_CODE (vide = inscription ouverte)
+ Kubeconfig string
+ FrontendImage string
+ BackendImage string
+}
+
+// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
+func Load() (Config, error) {
+ secret := os.Getenv("OMNEX_JWT_SECRET")
+ if len(secret) < 32 {
+ return Config{}, errors.New("OMNEX_JWT_SECRET manquant ou < 32 octets")
+ }
+
+ cfg := Config{
+ Addr: getenv("OMNEX_ADDR", ":8080"),
+ JWTSecret: []byte(secret),
+ SessionTTL: 24 * time.Hour,
+ AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")),
+ Env: getenv("OMNEX_ENV", "dev"),
+ DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"),
+ RedisURL: os.Getenv("OMNEX_REDIS_URL"),
+ DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"),
+ RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"),
+ Kubeconfig: os.Getenv("KUBECONFIG"),
+ FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
+ BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
+ }
+
+ if cfg.Env == "prod" {
+ if cfg.DatabaseURL == "" {
+ return Config{}, errors.New("OMNEX_DATABASE_URL requis en prod")
+ }
+ if cfg.RedisURL == "" {
+ return Config{}, errors.New("OMNEX_REDIS_URL requis en prod (sessions)")
+ }
+ }
+ return cfg, nil
+}
+
+// Secure indique si les cookies doivent porter l'attribut Secure (HTTPS).
+func (c Config) Secure() bool { return c.Env == "prod" }
+
+func getenv(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
+
+func splitCSV(s string) []string {
+ var out []string
+ start := 0
+ for i := 0; i <= len(s); i++ {
+ if i == len(s) || s[i] == ',' {
+ if seg := s[start:i]; seg != "" {
+ out = append(out, seg)
+ }
+ start = i + 1
+ }
+ }
+ return out
+}
diff --git a/control-plane/api/internal/db/db.go b/control-plane/api/internal/db/db.go
new file mode 100644
index 0000000..74adaca
--- /dev/null
+++ b/control-plane/api/internal/db/db.go
@@ -0,0 +1,55 @@
+// Package db : connexion GORM (PostgreSQL) et migrations.
+package db
+
+import (
+ "fmt"
+ "time"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+ "github.com/omnex/control-plane/api/internal/demos"
+ "github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/sav"
+ "github.com/omnex/control-plane/api/internal/sub"
+)
+
+// Open ouvre une connexion GORM vers PostgreSQL et configure le pool.
+func Open(dsn string) (*gorm.DB, error) {
+ gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Warn),
+ SkipDefaultTransaction: true,
+ PrepareStmt: true, // requêtes préparées : perf + anti-injection
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ouverture db: %w", err)
+ }
+
+ sqlDB, err := gdb.DB()
+ if err != nil {
+ return nil, err
+ }
+ sqlDB.SetMaxOpenConns(20)
+ sqlDB.SetMaxIdleConns(5)
+ sqlDB.SetConnMaxLifetime(time.Hour)
+ return gdb, nil
+}
+
+// AutoMigrate crée/actualise le schéma des entités du control-plane.
+func AutoMigrate(gdb *gorm.DB) error {
+ return gdb.AutoMigrate(
+ &auth.User{},
+ &sub.CodeBuySub{},
+ &leads.Lead{},
+ &demos.Demo{},
+ &demos.ExternalResource{},
+ &sav.Contact{},
+ )
+}
+
+// SeedExternalPool remplit le pool de ressources externes (idempotent).
+func SeedExternalPool(gdb *gorm.DB) error {
+ return demos.SeedPool(gdb)
+}
diff --git a/control-plane/api/internal/demos/demos.go b/control-plane/api/internal/demos/demos.go
new file mode 100644
index 0000000..d2e0be9
--- /dev/null
+++ b/control-plane/api/internal/demos/demos.go
@@ -0,0 +1,96 @@
+// Package demos : cœur métier — cycle de vie des démos client déployées
+// dans le cluster (une démo = un namespace isolé, TTL 30 jours).
+package demos
+
+import "time"
+
+// Paramètres capacité / durée de vie.
+const (
+ TTL = 30 * 24 * time.Hour // durée de vie d'une démo
+ MaxConcurrentDemos = 5 // capacité cible
+ PoolSizePerService = 5 // slots par service externe poolé
+)
+
+// Services externes gérés par pool pré-provisionné (non auto-créables par API).
+// TomTom n'y figure pas : ses clés sont créées à la volée par le worker.
+const (
+ ServiceTelegram = "telegram"
+ ServiceNowPayments = "nowpayments"
+)
+
+// PooledServices : services nécessitant un slot de pool par démo.
+var PooledServices = []string{ServiceTelegram, ServiceNowPayments}
+
+// Status : état d'une démo.
+type Status string
+
+const (
+ StatusPending Status = "pending" // créée, pas encore prise par le worker
+ StatusProvisioning Status = "provisioning" // worker en train de déployer
+ StatusReady Status = "ready" // démo accessible
+ StatusExpiring Status = "expiring" // teardown en cours
+ StatusExpired Status = "expired" // détruite / TTL atteint
+ StatusFailed Status = "failed" // échec de provisioning
+)
+
+func (s Status) Valid() bool {
+ switch s {
+ case StatusPending, StatusProvisioning, StatusReady, StatusExpiring, StatusExpired, StatusFailed:
+ return true
+ }
+ return false
+}
+
+// Active indique si la démo occupe des ressources (compte dans la capacité).
+func (s Status) Active() bool {
+ switch s {
+ case StatusPending, StatusProvisioning, StatusReady, StatusExpiring:
+ return true
+ }
+ return false
+}
+
+// Extendable : on ne prolonge que des démos encore vivantes.
+func (s Status) Extendable() bool {
+ switch s {
+ case StatusProvisioning, StatusReady:
+ return true
+ }
+ return false
+}
+
+// Demo : modèle GORM d'une démo.
+type Demo struct {
+ ID string `gorm:"type:uuid;primaryKey" json:"id"`
+ Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
+ LeadID string `gorm:"type:varchar(36);index" json:"lead_id,omitempty"`
+ Status Status `gorm:"size:20;not null;index" json:"status"`
+ Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
+ URL string `gorm:"size:255" json:"url"`
+ TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (Demo) TableName() string { return "demos" }
+
+// ResourceStatus : état d'un slot de pool.
+type ResourceStatus string
+
+const (
+ ResourceFree ResourceStatus = "free"
+ ResourceBorrowed ResourceStatus = "borrowed"
+)
+
+// ExternalResource : slot pré-provisionné (table external_pool).
+// On stocke une *référence* au secret (SecretRef), jamais le secret en clair.
+type ExternalResource struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Service string `gorm:"size:20;not null;index:idx_service_status" json:"service"`
+ Label string `gorm:"size:120" json:"label"` // ex. "@omnex_demo_bot_1"
+ SecretRef string `gorm:"size:200;not null" json:"-"` // clé dans le secret manager
+ Status ResourceStatus `gorm:"size:20;not null;index:idx_service_status" json:"status"`
+ DemoID string `gorm:"type:varchar(36);index" json:"demo_id,omitempty"` // FK optionnelle (vide si libre)
+}
+
+func (ExternalResource) TableName() string { return "external_pool" }
diff --git a/control-plane/api/internal/demos/handler.go b/control-plane/api/internal/demos/handler.go
new file mode 100644
index 0000000..5794d1b
--- /dev/null
+++ b/control-plane/api/internal/demos/handler.go
@@ -0,0 +1,104 @@
+package demos
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+type Handler struct{ svc *Service }
+
+func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
+
+type createRequest struct {
+ LeadID string `json:"lead_id" binding:"omitempty,uuid4"`
+ Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
+}
+
+// Create : POST /demos — provisionne une démo (202 Accepted, worker asynchrone).
+func (h *Handler) Create(c *gin.Context) {
+ var req createRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+ d, err := h.svc.Create(req.LeadID, req.Username)
+ if err != nil {
+ if errors.Is(err, ErrCapacityReached) {
+ c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusAccepted, d)
+}
+
+// List : GET /demos.
+func (h *Handler) List(c *gin.Context) {
+ items, err := h.svc.List()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items})
+}
+
+// ListMine : GET /demos/mine — démos rattachées au client authentifié.
+func (h *Handler) ListMine(c *gin.Context) {
+ p := auth.PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+ items, err := h.svc.ListForUser(p.Username)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items})
+}
+
+// Get : GET /demos/:id.
+func (h *Handler) Get(c *gin.Context) {
+ d, err := h.svc.Get(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
+ return
+ }
+ c.JSON(http.StatusOK, d)
+}
+
+// Delete : DELETE /demos/:id — teardown + libération du pool.
+func (h *Handler) Delete(c *gin.Context) {
+ d, err := h.svc.Delete(c.Param("id"))
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusOK, d)
+}
+
+// Extend : POST /demos/:id/extend — prolonge de 30 jours.
+func (h *Handler) Extend(c *gin.Context) {
+ d, err := h.svc.Extend(c.Param("id"))
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
+ case errors.Is(err, ErrNotExtendable):
+ c.JSON(http.StatusConflict, gin.H{"error": "démo non prolongeable"})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ }
+ return
+ }
+ c.JSON(http.StatusOK, d)
+}
diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go
new file mode 100644
index 0000000..855fba1
--- /dev/null
+++ b/control-plane/api/internal/demos/helm_provisioner.go
@@ -0,0 +1,337 @@
+package demos
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/omnex/control-plane/api/internal/config"
+ "go.yaml.in/yaml/v2"
+ k8sCoreV1 "k8s.io/api/core/v1"
+ k8sErrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+)
+
+// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
+// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm).
+type HelmProvisioner struct {
+ cfg *config.Config
+ k8sClient *kubernetes.Clientset
+ chartsDir string // chemin vers le dossier des charts (ex: /charts)
+ frontendImage string
+ backendImage string
+ baseDomain string
+ helmPath string // chemin vers l'exécutable helm (default: "helm")
+}
+
+// NewHelmProvisioner crée un nouveau provisioner Helm.
+// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
+// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
+func NewHelmProvisioner(
+ cfg *config.Config,
+ k8sClient *kubernetes.Clientset,
+ chartsDir string,
+ helmPath string,
+) (*HelmProvisioner, error) {
+ if chartsDir == "" {
+ chartsDir = "/charts"
+ }
+ if helmPath == "" {
+ helmPath = "helm"
+ }
+
+ // Vérifier que helm est disponible
+ if _, err := exec.LookPath(helmPath); err != nil {
+ return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err)
+ }
+
+ return &HelmProvisioner{
+ cfg: cfg,
+ k8sClient: k8sClient,
+ chartsDir: chartsDir,
+ frontendImage: cfg.FrontendImage,
+ backendImage: cfg.BackendImage,
+ baseDomain: cfg.DemoDomain,
+ helmPath: helmPath,
+ }, nil
+}
+
+// Provision déploie une démo avec Helm :
+// 1. Crée le namespace
+// 2. Installe PostgreSQL pour la démo
+// 3. Installe Redis pour la démo
+// 4. Installe le chart backend
+// 5. Installe le chart frontend
+func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error {
+ // Créer le namespace
+ if err := h.createNamespace(d.Namespace); err != nil {
+ return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
+ }
+
+ // Préparer les valeurs
+ postgresValues := h.buildPostgresValues(d)
+ redisValues := h.buildRedisValues(d)
+ backendValues := h.buildBackendValues(d, resources)
+ frontendValues := h.buildFrontendValues(d)
+
+ // Installer PostgreSQL (nécessaire pour le backend)
+ if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil {
+ h.deleteNamespace(d.Namespace)
+ return fmt.Errorf("déploiement postgresql: %w", err)
+ }
+
+ // Installer Redis (nécessaire pour le backend)
+ if err := h.installChart(d.Namespace, "redis", redisValues); err != nil {
+ h.deleteNamespace(d.Namespace)
+ return fmt.Errorf("déploiement redis: %w", err)
+ }
+
+ // Installer le backend
+ if err := h.installChart(d.Namespace, "backend", backendValues); err != nil {
+ h.deleteNamespace(d.Namespace)
+ return fmt.Errorf("déploiement backend: %w", err)
+ }
+
+ // Installer le frontend
+ if err := h.installChart(d.Namespace, "frontend", frontendValues); err != nil {
+ h.deleteNamespace(d.Namespace)
+ return fmt.Errorf("déploiement frontend: %w", err)
+ }
+
+ // Attendre que les pods soient prêts
+ if err := h.waitForRollout(d.Namespace); err != nil {
+ log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err)
+ }
+
+ return nil
+}
+
+// Teardown détruit une démo en supprimant son namespace (cascading).
+func (h *HelmProvisioner) Teardown(d Demo) error {
+ return h.deleteNamespace(d.Namespace)
+}
+
+// createNamespace crée un namespace Kubernetes.
+func (h *HelmProvisioner) createNamespace(name string) error {
+ ns := &k8sCoreV1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Labels: map[string]string{
+ "omnex.app/demo": "true",
+ },
+ },
+ }
+ _, err := h.k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
+ if err != nil && !k8sErrors.IsAlreadyExists(err) {
+ return err
+ }
+ return nil
+}
+
+// deleteNamespace supprime un namespace (avec cascading).
+func (h *HelmProvisioner) deleteNamespace(name string) error {
+ propagationPolicy := metav1.DeletePropagationForeground
+ return h.k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{
+ PropagationPolicy: &propagationPolicy,
+ })
+}
+
+// installChart installe un chart Helm avec des valeurs personnalisées.
+func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
+ chartPath := filepath.Join(h.chartsDir, chartName)
+
+ // Créer un fichier values.yaml temporaire
+ valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName))
+ valuesYAML, err := yaml.Marshal(values)
+ if err != nil {
+ return fmt.Errorf("marshal YAML: %w", err)
+ }
+ if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil {
+ return fmt.Errorf("write values file: %w", err)
+ }
+ defer os.Remove(valuesFile)
+
+ // Construire la commande helm install
+ args := []string{
+ "install",
+ fmt.Sprintf("%s-%s", namespace, chartName),
+ chartPath,
+ "--namespace", namespace,
+ "--values", valuesFile,
+ "--wait",
+ "--timeout", "5m",
+ }
+
+ cmd := exec.Command(h.helmPath, args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("helm install %s: %v (stdout: %s, stderr: %s)", chartName, err, stdout.String(), stderr.String())
+ }
+
+ log.Printf("Chart %s installé dans %s", chartName, namespace)
+ return nil
+}
+
+func parseImage(image string) (repo string, tag string) {
+ if image == "" {
+ return "", "helm"
+ }
+ // Chercher le dernier ":" (pour gérer les images avec port dans le repo)
+ idx := strings.LastIndex(image, ":")
+ if idx == -1 {
+ return image, "helm"
+ }
+ return image[:idx], image[idx+1:]
+}
+
+// buildBackendValues construit les valeurs pour le chart backend.
+func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} {
+ // Parser l'image backend pour séparer repository et tag
+ backendRepo, backendTag := parseImage(h.backendImage)
+
+ values := map[string]interface{}{
+ "replicaCount": 1,
+ "image": map[string]interface{}{
+ "repository": backendRepo,
+ "tag": backendTag,
+ "pullPolicy": "Always",
+ },
+ "service": map[string]interface{}{
+ "type": "ClusterIP",
+ "port": 8080,
+ },
+ "autoscaling": map[string]interface{}{
+ "enabled": false,
+ },
+ "persistence": map[string]interface{}{
+ "uploads": map[string]interface{}{
+ "enabled": false,
+ },
+ },
+ "env": map[string]string{
+ "DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace),
+ "DB_PORT": "5432",
+ "DB_PASSWORD": "demo-postgres-pass",
+ "DB_USER": "postgres",
+ "DB_NAME": "demo_db",
+ "DB_SSLMODE": "disable",
+ "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace),
+ "REDIS_PORT": "6379",
+ "REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis
+ "API_PORT": "8080",
+ "NODE_ENV": "production",
+ },
+ "secrets": h.buildSecrets(resources),
+ }
+
+ return values
+}
+
+// buildFrontendValues construit les valeurs pour le chart frontend.
+func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} {
+ backendURL := fmt.Sprintf("http://%s-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
+ // Parser l'image frontend pour séparer repository et tag
+ frontendRepo, frontendTag := parseImage(h.frontendImage)
+
+ return map[string]interface{}{
+ "replicaCount": 1,
+ "image": map[string]interface{}{
+ "repository": frontendRepo,
+ "tag": frontendTag,
+ "pullPolicy": "Always",
+ },
+ "apiUrl": backendURL,
+ "service": map[string]interface{}{
+ "type": "ClusterIP",
+ "port": 80,
+ },
+ "autoscaling": map[string]interface{}{
+ "enabled": false,
+ },
+ }
+}
+
+// buildSecrets extrait les références des secrets des ressources externes.
+func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string {
+ secrets := map[string]string{}
+ for _, r := range resources {
+ switch r.Service {
+ case ServiceTelegram:
+ secrets["TELEGRAM_WEBHOOK_SECRET"] = r.SecretRef
+ case ServiceNowPayments:
+ secrets["NOWPAYMENTS_IPN_SECRET"] = r.SecretRef
+ }
+ }
+ return secrets
+}
+
+// buildPostgresValues construit les valeurs pour le chart postgresql.
+func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} {
+ return map[string]interface{}{
+ "auth": map[string]interface{}{
+ "password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart)
+ "username": "postgres",
+ "database": "demo_db",
+ },
+ "service": map[string]interface{}{
+ "type": "ClusterIP",
+ "port": 5432,
+ },
+ "persistence": map[string]interface{}{
+ "enabled": false,
+ },
+ }
+}
+
+// buildRedisValues construit les valeurs pour le chart redis.
+func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} {
+ return map[string]interface{}{
+ "service": map[string]interface{}{
+ "type": "ClusterIP",
+ "port": 6379,
+ },
+ "auth": map[string]interface{}{
+ "password": "demo-redis-pass", // Mot de passe simple pour les démos
+ },
+ "persistence": map[string]interface{}{
+ "enabled": false, // Pas de persistence pour les démos
+ },
+ }
+}
+
+// waitForRollout attend que tous les déploiements dans le namespace soient prêts.
+func (h *HelmProvisioner) waitForRollout(namespace string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(5 * time.Second):
+ // Utiliser kubectl pour vérifier le rollout
+ cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ // Si c'est juste un timeout, on continue d'attendre
+ if strings.Contains(stderr.String(), "timed out") {
+ continue
+ }
+ return fmt.Errorf("rollout check: %v: %s", err, stderr.String())
+ }
+ // Si la commande réussit, tous les déploiements sont prêts
+ return nil
+ }
+ }
+}
diff --git a/control-plane/api/internal/demos/mem.go b/control-plane/api/internal/demos/mem.go
new file mode 100644
index 0000000..45c64a1
--- /dev/null
+++ b/control-plane/api/internal/demos/mem.go
@@ -0,0 +1,145 @@
+package demos
+
+import "sync"
+
+// MemStore : Store en mémoire (dev/tests).
+type MemStore struct {
+ mu sync.RWMutex
+ items map[string]Demo
+}
+
+func NewMemStore() *MemStore { return &MemStore{items: make(map[string]Demo)} }
+
+func (m *MemStore) Create(d Demo) (Demo, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.items[d.ID] = d
+ return d, nil
+}
+
+func (m *MemStore) Get(id string) (Demo, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ d, ok := m.items[id]
+ return d, ok
+}
+
+func (m *MemStore) List() ([]Demo, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]Demo, 0, len(m.items))
+ for _, d := range m.items {
+ out = append(out, d)
+ }
+ return out, nil
+}
+
+func (m *MemStore) ListByUsername(username string) ([]Demo, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]Demo, 0)
+ for _, d := range m.items {
+ if d.Username == username {
+ out = append(out, d)
+ }
+ }
+ return out, nil
+}
+
+func (m *MemStore) Update(d Demo) (Demo, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.items[d.ID] = d
+ return d, nil
+}
+
+func (m *MemStore) CountActive() (int, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ n := 0
+ for _, d := range m.items {
+ if d.Status.Active() {
+ n++
+ }
+ }
+ return n, nil
+}
+
+// MemPool : Pool en mémoire (dev/tests), PoolSizePerService slots par service.
+type MemPool struct {
+ mu sync.Mutex
+ res []*ExternalResource
+ next uint
+}
+
+// NewMemPool construit un pool rempli (PoolSizePerService slots libres par service).
+func NewMemPool() *MemPool {
+ p := &MemPool{}
+ for _, svc := range PooledServices {
+ for i := 1; i <= PoolSizePerService; i++ {
+ p.next++
+ p.res = append(p.res, &ExternalResource{
+ ID: p.next,
+ Service: svc,
+ Label: placeholderLabel(svc, i),
+ SecretRef: "secretref://" + svc + "/slot-" + itoa(i),
+ Status: ResourceFree,
+ })
+ }
+ }
+ return p
+}
+
+func (p *MemPool) Borrow(demoID string) ([]ExternalResource, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ // Vérifie d'abord la dispo de chaque service (atomicité).
+ pick := make(map[string]*ExternalResource, len(PooledServices))
+ for _, svc := range PooledServices {
+ var found *ExternalResource
+ for _, r := range p.res {
+ if r.Service == svc && r.Status == ResourceFree {
+ found = r
+ break
+ }
+ }
+ if found == nil {
+ return nil, ErrPoolExhausted
+ }
+ pick[svc] = found
+ }
+ var out []ExternalResource
+ for _, r := range pick {
+ r.Status = ResourceBorrowed
+ r.DemoID = demoID
+ out = append(out, *r)
+ }
+ return out, nil
+}
+
+func (p *MemPool) Return(demoID string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ for _, r := range p.res {
+ if r.DemoID == demoID {
+ r.Status = ResourceFree
+ r.DemoID = ""
+ }
+ }
+ return nil
+}
+
+func (p *MemPool) FreeCount() (map[string]int, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ out := map[string]int{}
+ for _, svc := range PooledServices {
+ out[svc] = 0
+ }
+ for _, r := range p.res {
+ if r.Status == ResourceFree {
+ out[r.Service]++
+ }
+ }
+ return out, nil
+}
diff --git a/control-plane/api/internal/demos/pool.go b/control-plane/api/internal/demos/pool.go
new file mode 100644
index 0000000..44d1f80
--- /dev/null
+++ b/control-plane/api/internal/demos/pool.go
@@ -0,0 +1,127 @@
+package demos
+
+import (
+ "errors"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// ErrPoolExhausted : plus de slot libre pour au moins un service requis.
+var ErrPoolExhausted = errors.New("pool de ressources externes épuisé")
+
+// Pool : allocation des ressources externes pré-provisionnées.
+type Pool interface {
+ // Borrow réserve un slot par service requis, de façon atomique.
+ // Rend ErrPoolExhausted si un service n'a plus de slot libre.
+ Borrow(demoID string) ([]ExternalResource, error)
+ // Return libère tous les slots d'une démo.
+ Return(demoID string) error
+ // FreeCount renvoie le nombre de slots libres par service.
+ FreeCount() (map[string]int, error)
+}
+
+// GormPool : Pool adossé à PostgreSQL via GORM (verrou transactionnel).
+type GormPool struct{ db *gorm.DB }
+
+func NewGormPool(db *gorm.DB) *GormPool { return &GormPool{db: db} }
+
+func (p *GormPool) Borrow(demoID string) ([]ExternalResource, error) {
+ var borrowed []ExternalResource
+ err := p.db.Transaction(func(tx *gorm.DB) error {
+ for _, svc := range PooledServices {
+ var r ExternalResource
+ // SELECT ... FOR UPDATE SKIP LOCKED : évite la course entre deux démos.
+ err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
+ Where("service = ? AND status = ?", svc, ResourceFree).
+ First(&r).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrPoolExhausted
+ }
+ if err != nil {
+ return err
+ }
+ r.Status = ResourceBorrowed
+ r.DemoID = demoID
+ if err := tx.Save(&r).Error; err != nil {
+ return err
+ }
+ borrowed = append(borrowed, r)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return borrowed, nil
+}
+
+func (p *GormPool) Return(demoID string) error {
+ return p.db.Model(&ExternalResource{}).
+ Where("demo_id = ?", demoID).
+ Updates(map[string]any{"status": ResourceFree, "demo_id": ""}).Error
+}
+
+func (p *GormPool) FreeCount() (map[string]int, error) {
+ type row struct {
+ Service string
+ N int
+ }
+ var rows []row
+ err := p.db.Model(&ExternalResource{}).
+ Select("service, count(*) as n").
+ Where("status = ?", ResourceFree).
+ Group("service").Scan(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]int{}
+ for _, svc := range PooledServices {
+ out[svc] = 0
+ }
+ for _, r := range rows {
+ out[r.Service] = r.N
+ }
+ return out, nil
+}
+
+// SeedPool crée les slots manquants pour atteindre PoolSizePerService par service.
+// Idempotent : n'ajoute que ce qui manque. SecretRef pointe vers le secret manager.
+func SeedPool(db *gorm.DB) error {
+ for _, svc := range PooledServices {
+ var n int64
+ if err := db.Model(&ExternalResource{}).Where("service = ?", svc).Count(&n).Error; err != nil {
+ return err
+ }
+ for i := int(n) + 1; i <= PoolSizePerService; i++ {
+ res := ExternalResource{
+ Service: svc,
+ Label: placeholderLabel(svc, i),
+ SecretRef: "secretref://" + svc + "/slot-" + itoa(i),
+ Status: ResourceFree,
+ }
+ if err := db.Create(&res).Error; err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func placeholderLabel(svc string, i int) string {
+ return svc + "-slot-" + itoa(i)
+}
+
+func itoa(i int) string {
+ if i == 0 {
+ return "0"
+ }
+ var b [20]byte
+ pos := len(b)
+ for i > 0 {
+ pos--
+ b[pos] = byte('0' + i%10)
+ i /= 10
+ }
+ return string(b[pos:])
+}
diff --git a/control-plane/api/internal/demos/provisioner.go b/control-plane/api/internal/demos/provisioner.go
new file mode 100644
index 0000000..eb59823
--- /dev/null
+++ b/control-plane/api/internal/demos/provisioner.go
@@ -0,0 +1,18 @@
+package demos
+
+// Provisioner : abstraction du worker qui déploie/détruit la stack dans le cluster.
+// L'implémentation réelle (Helm + client-go) sera branchée plus tard ; l'API se
+// contente d'enfiler les intentions.
+type Provisioner interface {
+ // Provision demande le déploiement d'une démo (asynchrone).
+ Provision(d Demo, resources []ExternalResource) error
+ // Teardown demande la destruction d'une démo.
+ Teardown(d Demo) error
+}
+
+// NoopProvisioner : implémentation neutre (aucun déploiement réel).
+// Sert au scaffolding et aux tests tant que le worker n'existe pas.
+type NoopProvisioner struct{}
+
+func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil }
+func (NoopProvisioner) Teardown(Demo) error { return nil }
diff --git a/control-plane/api/internal/demos/service.go b/control-plane/api/internal/demos/service.go
new file mode 100644
index 0000000..bb99fcb
--- /dev/null
+++ b/control-plane/api/internal/demos/service.go
@@ -0,0 +1,172 @@
+package demos
+
+import (
+ "errors"
+ "log"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+var (
+ ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
+ ErrNotFound = errors.New("démo introuvable")
+ ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
+)
+
+// Config du service (injectable pour les tests).
+type Config struct {
+ BaseDomain string // ex. "demo.omnex.app"
+ TTL time.Duration // durée de vie
+ MaxDemos int // capacité
+}
+
+// Service : logique métier des démos (indépendante du transport HTTP).
+type Service struct {
+ store Store
+ pool Pool
+ prov Provisioner
+ cfg Config
+ now func() time.Time // horloge injectable
+}
+
+func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
+ if cfg.TTL == 0 {
+ cfg.TTL = TTL
+ }
+ if cfg.MaxDemos == 0 {
+ cfg.MaxDemos = MaxConcurrentDemos
+ }
+ if cfg.BaseDomain == "" {
+ cfg.BaseDomain = "demo.omnex.app"
+ }
+ return &Service{store: store, pool: pool, prov: prov, cfg: cfg, now: time.Now}
+}
+
+// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
+// username (optionnel) rattache la démo à un client existant.
+func (s *Service) Create(leadID, username string) (Demo, error) {
+ active, err := s.store.CountActive()
+ if err != nil {
+ return Demo{}, err
+ }
+ if active >= s.cfg.MaxDemos {
+ return Demo{}, ErrCapacityReached
+ }
+
+ id := uuid.NewString()
+ demo := Demo{
+ ID: id,
+ Username: auth.NormalizeUsername(username),
+ LeadID: leadID,
+ Status: StatusPending,
+ Namespace: "demo-" + shortID(id),
+ CreatedAt: s.now().UTC(),
+ ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
+ }
+ demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain
+
+ // Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte.
+ resources, err := s.pool.Borrow(id)
+ if err != nil {
+ if errors.Is(err, ErrPoolExhausted) {
+ return Demo{}, ErrCapacityReached
+ }
+ return Demo{}, err
+ }
+
+ demo.Status = StatusProvisioning
+ created, err := s.store.Create(demo)
+ if err != nil {
+ _ = s.pool.Return(id) // fail-secure : on rend ce qu'on a emprunté
+ return Demo{}, err
+ }
+
+ // Lance le déploiement de manière asynchrone (worker inline via goroutine).
+ go func() {
+ if err := s.prov.Provision(created, resources); err != nil {
+ log.Printf("Provisioning échoué pour %s: %v", created.ID, err)
+ created.Status = StatusFailed
+ _, _ = s.store.Update(created)
+ _ = s.pool.Return(id)
+ return
+ }
+ created.Status = StatusReady
+ _, _ = s.store.Update(created)
+ }()
+
+ return created, nil
+}
+
+func (s *Service) Get(id string) (Demo, error) {
+ d, ok := s.store.Get(id)
+ if !ok {
+ return Demo{}, ErrNotFound
+ }
+ return d, nil
+}
+
+func (s *Service) List() ([]Demo, error) {
+ return s.store.List()
+}
+
+// ListForUser retourne uniquement les démos rattachées à ce client.
+func (s *Service) ListForUser(username string) ([]Demo, error) {
+ return s.store.ListByUsername(auth.NormalizeUsername(username))
+}
+
+// Delete déclenche le teardown et libère le pool.
+func (s *Service) Delete(id string) (Demo, error) {
+ d, ok := s.store.Get(id)
+ if !ok {
+ return Demo{}, ErrNotFound
+ }
+ if d.Status == StatusExpired {
+ return d, nil // déjà détruite (idempotent)
+ }
+ d.Status = StatusExpiring
+ if _, err := s.store.Update(d); err != nil {
+ return Demo{}, err
+ }
+ if err := s.prov.Teardown(d); err != nil {
+ return Demo{}, err
+ }
+ if err := s.pool.Return(id); err != nil {
+ return Demo{}, err
+ }
+ d.Status = StatusExpired
+ return s.store.Update(d)
+}
+
+// Extend prolonge la démo de TTL à partir de son échéance courante.
+func (s *Service) Extend(id string) (Demo, error) {
+ d, ok := s.store.Get(id)
+ if !ok {
+ return Demo{}, ErrNotFound
+ }
+ if !d.Status.Extendable() {
+ return Demo{}, ErrNotExtendable
+ }
+ base := d.ExpiresAt
+ if now := s.now().UTC(); base.Before(now) {
+ base = now
+ }
+ d.ExpiresAt = base.Add(s.cfg.TTL)
+ return s.store.Update(d)
+}
+
+// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
+func shortID(id string) string {
+ clean := ""
+ for _, c := range id {
+ if c != '-' {
+ clean += string(c)
+ }
+ if len(clean) == 8 {
+ break
+ }
+ }
+ return clean
+}
diff --git a/control-plane/api/internal/demos/store.go b/control-plane/api/internal/demos/store.go
new file mode 100644
index 0000000..7b7af11
--- /dev/null
+++ b/control-plane/api/internal/demos/store.go
@@ -0,0 +1,70 @@
+package demos
+
+import (
+ "errors"
+
+ "gorm.io/gorm"
+)
+
+// Store : persistance des démos.
+type Store interface {
+ Create(d Demo) (Demo, error)
+ Get(id string) (Demo, bool)
+ List() ([]Demo, error)
+ ListByUsername(username string) ([]Demo, error)
+ Update(d Demo) (Demo, error)
+ CountActive() (int, error)
+}
+
+// GormStore : Store adossé à PostgreSQL via GORM.
+type GormStore struct{ db *gorm.DB }
+
+func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} }
+
+func (s *GormStore) Create(d Demo) (Demo, error) {
+ if err := s.db.Create(&d).Error; err != nil {
+ return Demo{}, err
+ }
+ return d, nil
+}
+
+func (s *GormStore) Get(id string) (Demo, bool) {
+ var d Demo
+ err := s.db.Where("id = ?", id).First(&d).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
+ return Demo{}, false
+ }
+ return d, true
+}
+
+func (s *GormStore) List() ([]Demo, error) {
+ var out []Demo
+ if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (s *GormStore) ListByUsername(username string) ([]Demo, error) {
+ var out []Demo
+ if err := s.db.Where("username = ?", username).Order("created_at DESC").Find(&out).Error; err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (s *GormStore) Update(d Demo) (Demo, error) {
+ if err := s.db.Save(&d).Error; err != nil {
+ return Demo{}, err
+ }
+ return d, nil
+}
+
+// CountActive compte les démos occupant de la capacité.
+func (s *GormStore) CountActive() (int, error) {
+ var n int64
+ err := s.db.Model(&Demo{}).
+ Where("status IN ?", []Status{StatusPending, StatusProvisioning, StatusReady, StatusExpiring}).
+ Count(&n).Error
+ return int(n), err
+}
diff --git a/control-plane/api/internal/httpx/security.go b/control-plane/api/internal/httpx/security.go
new file mode 100644
index 0000000..7b98c5e
--- /dev/null
+++ b/control-plane/api/internal/httpx/security.go
@@ -0,0 +1,81 @@
+// Package httpx : middlewares transverses (sécurité, CORS, rate-limit).
+package httpx
+
+import (
+ "net/http"
+ "sync"
+
+ "github.com/gin-gonic/gin"
+ "golang.org/x/time/rate"
+)
+
+// SecurityHeaders ajoute les en-têtes de sécurité recommandés (OWASP).
+func SecurityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ h := c.Writer.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
+ h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
+ c.Next()
+ }
+}
+
+// CORS restreint les origines à une allowlist explicite.
+func CORS(allowed []string) gin.HandlerFunc {
+ set := make(map[string]struct{}, len(allowed))
+ for _, o := range allowed {
+ set[o] = struct{}{}
+ }
+ return func(c *gin.Context) {
+ origin := c.GetHeader("Origin")
+ if _, ok := set[origin]; ok {
+ h := c.Writer.Header()
+ h.Set("Access-Control-Allow-Origin", origin)
+ h.Set("Vary", "Origin")
+ h.Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
+ h.Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
+ }
+ if c.Request.Method == http.MethodOptions {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+ c.Next()
+ }
+}
+
+// ipLimiter : rate-limiter par IP (token bucket).
+type ipLimiter struct {
+ mu sync.Mutex
+ buck map[string]*rate.Limiter
+ r rate.Limit
+ burst int
+}
+
+func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
+ return &ipLimiter{buck: make(map[string]*rate.Limiter), r: r, burst: burst}
+}
+
+func (l *ipLimiter) get(ip string) *rate.Limiter {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ lim, ok := l.buck[ip]
+ if !ok {
+ lim = rate.NewLimiter(l.r, l.burst)
+ l.buck[ip] = lim
+ }
+ return lim
+}
+
+// RateLimit limite chaque IP à r req/s avec un burst donné.
+func RateLimit(perSecond float64, burst int) gin.HandlerFunc {
+ limiter := newIPLimiter(rate.Limit(perSecond), burst)
+ return func(c *gin.Context) {
+ if !limiter.get(c.ClientIP()).Allow() {
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "trop de requêtes"})
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/control-plane/api/internal/k8s/client.go b/control-plane/api/internal/k8s/client.go
new file mode 100644
index 0000000..0147df2
--- /dev/null
+++ b/control-plane/api/internal/k8s/client.go
@@ -0,0 +1,34 @@
+package k8s
+
+import (
+ "fmt"
+
+ "github.com/omnex/control-plane/api/internal/config"
+
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+)
+
+func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
+ var (
+ k8sCfg *rest.Config
+ err error
+ )
+
+ if cfg.Kubeconfig != "" {
+ k8sCfg, err = clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig)
+ } else {
+ k8sCfg, err = rest.InClusterConfig()
+ }
+ if err != nil {
+ return nil, fmt.Errorf("k8s config: %w", err)
+ }
+
+ client, err := kubernetes.NewForConfig(k8sCfg)
+ if err != nil {
+ return nil, fmt.Errorf("k8s clientset: %w", err)
+ }
+
+ return client, nil
+}
diff --git a/control-plane/api/internal/leads/gormstore.go b/control-plane/api/internal/leads/gormstore.go
new file mode 100644
index 0000000..1b94114
--- /dev/null
+++ b/control-plane/api/internal/leads/gormstore.go
@@ -0,0 +1,57 @@
+package leads
+
+import (
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// GormStore : Store adossé à PostgreSQL via GORM.
+type GormStore struct {
+ db *gorm.DB
+}
+
+func NewGormStore(db *gorm.DB) *GormStore {
+ return &GormStore{db: db}
+}
+
+func (s *GormStore) Create(l Lead) (Lead, error) {
+ l.ID = uuid.NewString()
+ l.Status = StatusNew
+ l.CreatedAt = time.Now().UTC()
+ if err := s.db.Create(&l).Error; err != nil {
+ return Lead{}, err
+ }
+ return l, nil
+}
+
+func (s *GormStore) List() ([]Lead, error) {
+ var out []Lead
+ if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (s *GormStore) Get(id string) (Lead, bool) {
+ var l Lead
+ err := s.db.Where("id = ?", id).First(&l).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
+ return Lead{}, false
+ }
+ return l, true
+}
+
+func (s *GormStore) SetStatus(id string, status Status) (Lead, bool) {
+ var l Lead
+ if err := s.db.Where("id = ?", id).First(&l).Error; err != nil {
+ return Lead{}, false
+ }
+ l.Status = status
+ if err := s.db.Save(&l).Error; err != nil {
+ return Lead{}, false
+ }
+ return l, true
+}
diff --git a/control-plane/api/internal/leads/handler.go b/control-plane/api/internal/leads/handler.go
new file mode 100644
index 0000000..d127535
--- /dev/null
+++ b/control-plane/api/internal/leads/handler.go
@@ -0,0 +1,77 @@
+package leads
+
+import (
+ "html"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Handler struct {
+ store Store
+}
+
+func NewHandler(store Store) *Handler {
+ return &Handler{store: store}
+}
+
+// createRequest : entrée publique (formulaire vitrine). Validation stricte.
+type createRequest struct {
+ Telegram string `json:"company" binding:"required,min=2,max=120"`
+ Message string `json:"message" binding:"max=2000"`
+}
+
+// Create enregistre un lead depuis le formulaire public.
+// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
+func (h *Handler) Create(c *gin.Context) {
+ var req createRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+ l, err := h.store.Create(Lead{
+ Telegram: sanitize(req.Telegram),
+ Message: sanitize(req.Message),
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusCreated, l)
+}
+
+// List renvoie tous les leads (back-office, protégé).
+func (h *Handler) List(c *gin.Context) {
+ items, err := h.store.List()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items})
+}
+
+type statusRequest struct {
+ Status Status `json:"status" binding:"required"`
+}
+
+// SetStatus met à jour le statut d'un lead (back-office, protégé).
+func (h *Handler) SetStatus(c *gin.Context) {
+ id := c.Param("id")
+ var req statusRequest
+ if err := c.ShouldBindJSON(&req); err != nil || !req.Status.Valid() {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "statut invalide"})
+ return
+ }
+ l, ok := h.store.SetStatus(id, req.Status)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "lead introuvable"})
+ return
+ }
+ c.JSON(http.StatusOK, l)
+}
+
+// sanitize : trim + échappement HTML pour neutraliser le XSS stocké.
+func sanitize(s string) string {
+ return html.EscapeString(strings.TrimSpace(s))
+}
diff --git a/control-plane/api/internal/leads/leads.go b/control-plane/api/internal/leads/leads.go
new file mode 100644
index 0000000..54d05dd
--- /dev/null
+++ b/control-plane/api/internal/leads/leads.go
@@ -0,0 +1,96 @@
+// Package leads : gestion des prospects (feature "leads" du back-office).
+package leads
+
+import (
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Status string
+
+const (
+ StatusNew Status = "new"
+ StatusContacted Status = "contacted"
+ StatusDemo Status = "demo"
+ StatusWon Status = "won"
+ StatusLost Status = "lost"
+)
+
+func (s Status) Valid() bool {
+ switch s {
+ case StatusNew, StatusContacted, StatusDemo, StatusWon, StatusLost:
+ return true
+ }
+ return false
+}
+
+// Lead : prospect. Modèle GORM.
+type Lead struct {
+ ID string `gorm:"type:uuid;primaryKey" json:"id"`
+ Telegram string `gorm:"size:120;not null" json:"telegram"`
+ Message string `gorm:"size:2000" json:"message"`
+ Status Status `gorm:"size:20;not null;index" json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// TableName force le nom de table.
+func (Lead) TableName() string { return "leads" }
+
+// Store : persistance des leads. Impl mémoire ici, Postgres plus tard.
+type Store interface {
+ Create(l Lead) (Lead, error)
+ List() ([]Lead, error)
+ Get(id string) (Lead, bool)
+ SetStatus(id string, s Status) (Lead, bool)
+}
+
+// MemStore : implémentation en mémoire (dev/tests).
+type MemStore struct {
+ mu sync.RWMutex
+ items map[string]Lead
+}
+
+func NewMemStore() *MemStore {
+ return &MemStore{items: make(map[string]Lead)}
+}
+
+func (m *MemStore) Create(l Lead) (Lead, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ l.ID = uuid.NewString()
+ l.Status = StatusNew
+ l.CreatedAt = time.Now().UTC()
+ m.items[l.ID] = l
+ return l, nil
+}
+
+func (m *MemStore) List() ([]Lead, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]Lead, 0, len(m.items))
+ for _, l := range m.items {
+ out = append(out, l)
+ }
+ return out, nil
+}
+
+func (m *MemStore) Get(id string) (Lead, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ l, ok := m.items[id]
+ return l, ok
+}
+
+func (m *MemStore) SetStatus(id string, s Status) (Lead, bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ l, ok := m.items[id]
+ if !ok {
+ return Lead{}, false
+ }
+ l.Status = s
+ m.items[id] = l
+ return l, true
+}
diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go
new file mode 100644
index 0000000..da43f86
--- /dev/null
+++ b/control-plane/api/internal/router/router.go
@@ -0,0 +1,86 @@
+// Package router assemble les routes de l'API Omnex.
+package router
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+ "github.com/omnex/control-plane/api/internal/config"
+ "github.com/omnex/control-plane/api/internal/demos"
+ "github.com/omnex/control-plane/api/internal/httpx"
+ "github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/sav"
+ "github.com/omnex/control-plane/api/internal/session"
+ "github.com/omnex/control-plane/api/internal/sub"
+)
+
+// Deps : dépendances injectées (facilite les tests).
+type Deps struct {
+ Cfg config.Config
+ Issuer *auth.Issuer
+ Sessions session.Manager
+ AuthH *auth.Handler
+ LeadsH *leads.Handler
+ DemosH *demos.Handler
+ SubH *sub.Handler
+ ContactH *sav.Handler
+}
+
+// New construit l'engine Gin avec toute la chaîne de sécurité.
+func New(d Deps) *gin.Engine {
+ if d.Cfg.Env == "prod" {
+ gin.SetMode(gin.ReleaseMode)
+ }
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.Use(httpx.SecurityHeaders())
+ r.Use(httpx.CORS(d.Cfg.AllowedOrigins))
+
+ r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
+
+ api := r.Group("/api/v1")
+
+ // Public : login, inscription + formulaire de contact, rate-limités.
+ api.POST("/auth/login", httpx.RateLimit(1, 5), d.AuthH.Login)
+ api.POST("/auth/register", httpx.RateLimit(0.2, 3), d.AuthH.Register)
+ api.POST("/leads", httpx.RateLimit(1, 3), d.LeadsH.Create)
+ api.POST("/send/message", httpx.RateLimit(1, 3), d.ContactH.CallSupport)
+ // Toute route authentifiée (session valide, n'importe quel rôle).
+ authed := api.Group("")
+ authed.Use(auth.RequireAuth(d.Issuer, d.Sessions))
+ {
+ authed.POST("/auth/logout", d.AuthH.Logout)
+ authed.GET("/auth/me", d.AuthH.Me)
+ }
+
+ client := authed.Group("")
+ client.Use(auth.RequireRole(auth.RoleClient))
+ {
+ client.GET("/leads", d.LeadsH.List)
+ client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
+ client.POST("/subscription", d.SubH.AddCode)
+ if d.DemosH != nil {
+ client.GET("/demos/mine", d.DemosH.ListMine)
+ }
+ }
+
+ // Espace admin : provisioning des démos (admin uniquement).
+ admin := authed.Group("")
+ admin.Use(auth.RequireRole(auth.RoleAdmin))
+ {
+ admin.GET("/codes", d.SubH.ListCodes)
+ admin.POST("/codes", d.SubH.CreateCodeForBuy)
+ admin.GET("/messages", d.ContactH.GetMessage)
+ if d.DemosH != nil {
+ admin.POST("/demos", d.DemosH.Create)
+ admin.GET("/demos", d.DemosH.List)
+ admin.GET("/demos/:id", d.DemosH.Get)
+ admin.DELETE("/demos/:id", d.DemosH.Delete)
+ admin.POST("/demos/:id/extend", d.DemosH.Extend)
+ }
+ }
+
+ return r
+}
diff --git a/control-plane/api/internal/sav/handler.go b/control-plane/api/internal/sav/handler.go
new file mode 100644
index 0000000..d1832ed
--- /dev/null
+++ b/control-plane/api/internal/sav/handler.go
@@ -0,0 +1,60 @@
+package sav
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+type Handler struct {
+ store Store
+}
+
+// NewHandler crée un nouveau Handler.
+func NewHandler(store Store) *Handler {
+ return &Handler{store: store}
+}
+
+type contactSupport struct {
+ Username string `json:"username"`
+ Telegram string `json:"telegram"`
+ Sujet string `json:"sujet"`
+ Message string `json:"message"`
+}
+
+func (h *Handler) CallSupport(c *gin.Context) {
+ var req contactSupport
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+
+ contact, err := h.store.ContactSupportByUser(req.Username, req.Telegram, req.Sujet, req.Message)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{"success": "message envoyé", "contact": contact})
+}
+
+func (h *Handler) GetMessage(c *gin.Context) {
+ p := auth.PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+ if p.Role != "admin" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
+ return
+ }
+
+ getMessage, err := h.store.GetMessage()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+ c.JSON(http.StatusCreated, gin.H{"messages": getMessage})
+
+}
diff --git a/control-plane/api/internal/sav/mem.go b/control-plane/api/internal/sav/mem.go
new file mode 100644
index 0000000..5129d1d
--- /dev/null
+++ b/control-plane/api/internal/sav/mem.go
@@ -0,0 +1,49 @@
+package sav
+
+import (
+ "sort"
+ "sync"
+
+ "github.com/google/uuid"
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+type MemContactStore struct {
+ mu sync.RWMutex
+ items map[string]Contact
+}
+
+func NewMemContactStore() *MemContactStore {
+ return &MemContactStore{
+ items: make(map[string]Contact),
+ }
+}
+
+func (m *MemContactStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ contact := Contact{
+ ID: uuid.NewString(),
+ Username: auth.NormalizeUsername(username),
+ Telegram: auth.NormalizeUsername(telegram),
+ Sujet: sujet,
+ Message: message,
+ }
+
+ m.items[contact.ID] = contact
+ return contact, nil
+}
+
+func (m *MemContactStore) GetMessage() ([]Contact, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ contacts := make([]Contact, 0, len(m.items))
+ for _, c := range m.items {
+ contacts = append(contacts, c)
+ }
+ sort.Slice(contacts, func(i, j int) bool {
+ return contacts[i].CreatedAt.After(contacts[j].CreatedAt)
+ })
+ return contacts, nil
+}
diff --git a/control-plane/api/internal/sav/models.go b/control-plane/api/internal/sav/models.go
new file mode 100644
index 0000000..1de9623
--- /dev/null
+++ b/control-plane/api/internal/sav/models.go
@@ -0,0 +1,12 @@
+package sav
+
+import "time"
+
+type Contact struct {
+ ID string `gorm:"type:uuid;primaryKey" json:"id"`
+ Username string `gorm:"size:64;not null" json:"username"`
+ Telegram string `gorm:"size:64;not null" json:"telegram"`
+ Sujet string `gorm:"size:64;not null" json:"sujet"`
+ Message string `gorm:"size:64;not null" json:"message"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/control-plane/api/internal/sav/store.go b/control-plane/api/internal/sav/store.go
new file mode 100644
index 0000000..4384117
--- /dev/null
+++ b/control-plane/api/internal/sav/store.go
@@ -0,0 +1,43 @@
+package sav
+
+import (
+ "github.com/google/uuid"
+ "github.com/omnex/control-plane/api/internal/auth"
+ "gorm.io/gorm"
+)
+
+type Store interface {
+ ContactSupportByUser(username, telegram, sujet, message string) (Contact, error)
+ GetMessage() ([]Contact, error)
+}
+
+type GormStore struct {
+ db *gorm.DB
+}
+
+func NewGormStore(db *gorm.DB) *GormStore {
+ return &GormStore{db: db}
+}
+
+func (s *GormStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) {
+ contact := Contact{
+ ID: uuid.NewString(),
+ Username: auth.NormalizeUsername(username),
+ Telegram: auth.NormalizeUsername(telegram),
+ Sujet: sujet,
+ Message: message,
+ }
+
+ if err := s.db.Create(&contact).Error; err != nil {
+ return Contact{}, err
+ }
+ return contact, nil
+}
+
+func (s *GormStore) GetMessage() ([]Contact, error) {
+ var contacts []Contact
+ if err := s.db.Find(&contacts).Error; err != nil {
+ return nil, err
+ }
+ return contacts, nil
+}
diff --git a/control-plane/api/internal/session/mem.go b/control-plane/api/internal/session/mem.go
new file mode 100644
index 0000000..592bcd4
--- /dev/null
+++ b/control-plane/api/internal/session/mem.go
@@ -0,0 +1,54 @@
+package session
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// MemManager : Manager en mémoire (dev/tests), avec expiration paresseuse.
+type MemManager struct {
+ mu sync.RWMutex
+ items map[string]entry
+ ttl time.Duration
+}
+
+type entry struct {
+ s Session
+ expires time.Time
+}
+
+func NewMemManager(ttl time.Duration) *MemManager {
+ return &MemManager{items: make(map[string]entry), ttl: ttl}
+}
+
+func (m *MemManager) TTL() time.Duration { return m.ttl }
+
+func (m *MemManager) Create(_ context.Context, s Session) (string, error) {
+ token, err := newToken()
+ if err != nil {
+ return "", err
+ }
+ s.CreatedAt = time.Now().UTC()
+ m.mu.Lock()
+ m.items[token] = entry{s: s, expires: time.Now().Add(m.ttl)}
+ m.mu.Unlock()
+ return token, nil
+}
+
+func (m *MemManager) Get(_ context.Context, token string) (Session, bool, error) {
+ m.mu.RLock()
+ e, ok := m.items[token]
+ m.mu.RUnlock()
+ if !ok || time.Now().After(e.expires) {
+ return Session{}, false, nil
+ }
+ return e.s, true, nil
+}
+
+func (m *MemManager) Delete(_ context.Context, token string) error {
+ m.mu.Lock()
+ delete(m.items, token)
+ m.mu.Unlock()
+ return nil
+}
diff --git a/control-plane/api/internal/session/redis.go b/control-plane/api/internal/session/redis.go
new file mode 100644
index 0000000..f5449f9
--- /dev/null
+++ b/control-plane/api/internal/session/redis.go
@@ -0,0 +1,65 @@
+package session
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+)
+
+const keyPrefix = "omnex:session:"
+
+// RedisManager : Manager adossé à Redis.
+type RedisManager struct {
+ rdb *redis.Client
+ ttl time.Duration
+}
+
+func NewRedisManager(rdb *redis.Client, ttl time.Duration) *RedisManager {
+ return &RedisManager{rdb: rdb, ttl: ttl}
+}
+
+func (m *RedisManager) TTL() time.Duration { return m.ttl }
+
+func (m *RedisManager) Create(ctx context.Context, s Session) (string, error) {
+ token, err := newToken()
+ if err != nil {
+ return "", err
+ }
+ s.CreatedAt = time.Now().UTC()
+ data, err := json.Marshal(s)
+ if err != nil {
+ return "", err
+ }
+ if err := m.rdb.Set(ctx, keyPrefix+token, data, m.ttl).Err(); err != nil {
+ return "", err
+ }
+ return token, nil
+}
+
+func (m *RedisManager) Get(ctx context.Context, token string) (Session, bool, error) {
+ if token == "" {
+ return Session{}, false, nil
+ }
+ data, err := m.rdb.Get(ctx, keyPrefix+token).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return Session{}, false, nil
+ }
+ if err != nil {
+ return Session{}, false, err
+ }
+ var s Session
+ if err := json.Unmarshal(data, &s); err != nil {
+ return Session{}, false, err
+ }
+ return s, true, nil
+}
+
+func (m *RedisManager) Delete(ctx context.Context, token string) error {
+ if token == "" {
+ return nil
+ }
+ return m.rdb.Del(ctx, keyPrefix+token).Err()
+}
diff --git a/control-plane/api/internal/session/session.go b/control-plane/api/internal/session/session.go
new file mode 100644
index 0000000..8c5bd01
--- /dev/null
+++ b/control-plane/api/internal/session/session.go
@@ -0,0 +1,41 @@
+// Package session : sessions utilisateurs stockées côté serveur dans Redis.
+// Tokens opaques aléatoires (révocables), pas de JWT.
+package session
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "time"
+)
+
+// CookieName : nom du cookie de session (httpOnly).
+const CookieName = "omnex_session"
+
+// Session : données associées à un token de session.
+type Session struct {
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Manager : cycle de vie des sessions.
+type Manager interface {
+ // Create génère un token opaque et persiste la session (TTL appliqué).
+ Create(ctx context.Context, s Session) (token string, err error)
+ // Get récupère une session par token (found=false si absente/expirée).
+ Get(ctx context.Context, token string) (s Session, found bool, err error)
+ // Delete révoque une session (logout).
+ Delete(ctx context.Context, token string) error
+ // TTL renvoie la durée de vie configurée.
+ TTL() time.Duration
+}
+
+// newToken génère un token de session cryptographiquement aléatoire (256 bits).
+func newToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/control-plane/api/internal/sub/handler.go b/control-plane/api/internal/sub/handler.go
new file mode 100644
index 0000000..67b8cd2
--- /dev/null
+++ b/control-plane/api/internal/sub/handler.go
@@ -0,0 +1,126 @@
+package sub
+
+import (
+ "crypto/rand"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+// Handler gère les requêtes HTTP pour les codes de souscription.
+type Handler struct {
+ store Store
+}
+
+// NewHandler crée un nouveau Handler.
+func NewHandler(store Store) *Handler {
+ return &Handler{store: store}
+}
+
+// createCodeRequest représente la requête pour créer un code.
+type createCodeRequest struct {
+ Username string `json:"username" binding:"required,min=3,max=64,alphanum"`
+}
+
+type addCodeRequest struct {
+ Code string `json:"code_verif" binding:"required,len=19"`
+}
+
+func GenerateCode() (string, error) {
+ const length = 16
+ const groupSize = 4
+
+ b := make([]byte, length)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+
+ var sb strings.Builder
+ for i := 0; i < length; i++ {
+ if i > 0 && i%groupSize == 0 {
+ sb.WriteByte('-')
+ }
+ sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)])
+ }
+ return sb.String(), nil
+}
+
+const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+
+// ListCodes retourne la liste de tous les codes de souscription.
+// Réservé aux administrateurs.
+func (h *Handler) ListCodes(c *gin.Context) {
+ p := auth.PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+ if p.Role != "admin" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
+ return
+ }
+ codes, err := h.store.ListCodes()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"items": codes})
+}
+
+// CreateCodeForBuy crée un nouveau code de souscription pour un utilisateur.
+// Réservé aux administrateurs.
+func (h *Handler) CreateCodeForBuy(c *gin.Context) {
+ // Vérification de l'authentification et du rôle sera faite par le middleware
+ p := auth.PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+ if p.Role != "admin" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
+ return
+ }
+ var req createCodeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+
+ code, err := GenerateCode()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ codeSub, err := h.store.CreateCodeForSub(req.Username, code)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{"code": code, "code_id": codeSub.ID, "username": codeSub.Username})
+}
+
+func (h *Handler) AddCode(c *gin.Context) {
+ p := auth.PrincipalFrom(c)
+ if p == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
+ return
+ }
+
+ var req addCodeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
+ return
+ }
+
+ _, err := h.store.AddCodeForSub(p.Username, req.Code)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"})
+}
diff --git a/control-plane/api/internal/sub/mem.go b/control-plane/api/internal/sub/mem.go
new file mode 100644
index 0000000..0fb8522
--- /dev/null
+++ b/control-plane/api/internal/sub/mem.go
@@ -0,0 +1,56 @@
+package sub
+
+import (
+ "sync"
+
+ "github.com/google/uuid"
+ "github.com/omnex/control-plane/api/internal/auth"
+)
+
+// MemCodeStore : implémentation en mémoire de CodeStore (dev/tests).
+type MemCodeStore struct {
+ mu sync.RWMutex
+ items map[string]CodeBuySub
+ byCode map[string]CodeBuySub
+}
+
+func NewMemCodeStore() *MemCodeStore {
+ return &MemCodeStore{
+ items: make(map[string]CodeBuySub),
+ byCode: make(map[string]CodeBuySub),
+ }
+}
+
+func (m *MemCodeStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := CodeBuySub{
+ ID: uuid.NewString(),
+ Username: username,
+ CodeBuy: code,
+ }
+ m.items[c.ID] = c
+ m.byCode[code] = c
+ return c, nil
+}
+
+func (m *MemCodeStore) GetCodeForSub(username, code string) (auth.User, CodeBuySub, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ c, ok := m.byCode[code]
+ if !ok {
+ return auth.User{}, CodeBuySub{}, nil
+ }
+ // In a real implementation, we'd fetch the user too
+ return auth.User{}, c, nil
+}
+
+func (m *MemCodeStore) ListCodes() ([]CodeBuySub, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]CodeBuySub, 0, len(m.items))
+ for _, c := range m.items {
+ out = append(out, c)
+ }
+ return out, nil
+}
diff --git a/control-plane/api/internal/sub/models.go b/control-plane/api/internal/sub/models.go
new file mode 100644
index 0000000..90bd83c
--- /dev/null
+++ b/control-plane/api/internal/sub/models.go
@@ -0,0 +1,17 @@
+package sub
+
+import (
+ "time"
+)
+
+// CodeBuySub représente un code de souscription associé à un utilisateur.
+type CodeBuySub struct {
+ ID string `gorm:"type:uuid;primaryKey" json:"id"`
+ Username string `gorm:"size:64;not null" json:"username"`
+ CodeBuy string `gorm:"uniqueIndex;type:varchar(50);not null" json:"code_verif"`
+ CreatedAt time.Time `gorm:"index;autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+}
+
+// TableName définit explicitement le nom de la table.
+func (CodeBuySub) TableName() string { return "code_buy_subs" }
diff --git a/control-plane/api/internal/sub/store.go b/control-plane/api/internal/sub/store.go
new file mode 100644
index 0000000..39858ff
--- /dev/null
+++ b/control-plane/api/internal/sub/store.go
@@ -0,0 +1,176 @@
+package sub
+
+import (
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/omnex/control-plane/api/internal/auth"
+ "gorm.io/gorm"
+)
+
+// Store définit les opérations de persistance pour les codes de souscription.
+type Store interface {
+ // CreateCodeForSub crée un nouveau code pour un utilisateur.
+ CreateCodeForSub(username, code string) (CodeBuySub, error)
+ // GetCodeForSub récupère un code pour un utilisateur.
+ GetCodeForSub(username, code string) (CodeBuySub, error)
+ // ListCodes liste tous les codes.
+ ListCodes() ([]CodeBuySub, error)
+ AddCodeForSub(username, code string) (auth.User, error)
+}
+
+// GormStore implémente Store avec PostgreSQL via GORM.
+type GormStore struct {
+ db *gorm.DB
+}
+
+// NewGormStore crée un nouveau GormStore.
+func NewGormStore(db *gorm.DB) *GormStore {
+ return &GormStore{db: db}
+}
+
+// CreateCodeForSub crée un nouveau code pour un utilisateur.
+func (s *GormStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
+ c := CodeBuySub{
+ ID: uuid.NewString(),
+ Username: auth.NormalizeUsername(username),
+ CodeBuy: code,
+ }
+ if err := s.db.Create(&c).Error; err != nil {
+ return CodeBuySub{}, err
+ }
+
+ // S'assurer que CreatedAt est bien rempli
+ if c.CreatedAt.IsZero() {
+ c.CreatedAt = time.Now().UTC()
+ if err := s.db.Save(&c).Error; err != nil {
+ return CodeBuySub{}, err
+ }
+ }
+
+ return c, nil
+}
+
+// GetCodeForSub récupère un code pour un utilisateur.
+func (s *GormStore) GetCodeForSub(username, code string) (CodeBuySub, error) {
+ var c CodeBuySub
+ if err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error; err != nil {
+ return CodeBuySub{}, err
+ }
+ return c, nil
+}
+
+func (s *GormStore) AddCodeForSub(username, code string) (auth.User, error) {
+ var c CodeBuySub
+ err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return auth.User{}, errors.New("code invalide")
+ }
+ return auth.User{}, err
+ }
+
+ var u auth.User
+ if err := s.db.Where("username = ?", auth.NormalizeUsername(username)).First(&u).Error; err != nil {
+ return auth.User{}, err
+ }
+
+ u.TypeAbo = "premium"
+ u.ExpiredAt = time.Now().UTC().AddDate(0, 0, 30)
+ if err := s.db.Model(&u).Updates(map[string]interface{}{
+ "type_abo": u.TypeAbo,
+ "expired_at": u.ExpiredAt,
+ }).Error; err != nil {
+ return auth.User{}, err
+ }
+
+ if err := s.db.Delete(&c).Error; err != nil {
+ return auth.User{}, err
+ }
+
+ return u, nil
+}
+
+// ListCodes liste tous les codes, triés par date de création.
+func (s *GormStore) ListCodes() ([]CodeBuySub, error) {
+ var codes []CodeBuySub
+ // Essayer avec tri par date, sinon sans tri pour compatibilité
+ if err := s.db.Order("created_at desc").Find(&codes).Error; err != nil {
+ // Si colonne manquante, on liste sans tri
+ if orderErr := s.db.Find(&codes).Error; orderErr != nil {
+ return nil, orderErr
+ }
+ }
+ return codes, nil
+}
+
+// MemStore implémente Store en mémoire (pour dev/tests).
+type MemStore struct {
+ mu sync.RWMutex
+ items map[string]CodeBuySub
+ byCode map[string]CodeBuySub
+}
+
+// NewMemStore crée un nouveau MemStore.
+func NewMemStore() *MemStore {
+ return &MemStore{
+ items: make(map[string]CodeBuySub),
+ byCode: make(map[string]CodeBuySub),
+ }
+}
+
+// CreateCodeForSub crée un nouveau code pour un utilisateur.
+func (m *MemStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c := CodeBuySub{
+ ID: uuid.NewString(),
+ Username: username,
+ CodeBuy: code,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ m.items[c.ID] = c
+ m.byCode[code] = c
+ return c, nil
+}
+
+// GetCodeForSub récupère un code pour un utilisateur.
+func (m *MemStore) GetCodeForSub(username, code string) (CodeBuySub, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ c, ok := m.byCode[code]
+ if !ok {
+ return CodeBuySub{}, nil
+ }
+ return c, nil
+}
+
+// ListCodes liste tous les codes.
+func (m *MemStore) ListCodes() ([]CodeBuySub, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := make([]CodeBuySub, 0, len(m.items))
+ for _, c := range m.items {
+ out = append(out, c)
+ }
+ return out, nil
+}
+
+func (m *MemStore) AddCodeForSub(username, code string) (auth.User, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ c, ok := m.byCode[code]
+ if !ok || c.Username != username {
+ return auth.User{}, errors.New("code invalide")
+ }
+ delete(m.byCode, code)
+ delete(m.items, c.ID)
+
+ // Le MemStore ne connaît pas les utilisateurs (pas de référence à memUsers ici) :
+ // il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous.
+ return auth.User{Username: username, TypeAbo: "premium"}, nil
+}
diff --git a/control-plane/api/internal/test/auth_test.go b/control-plane/api/internal/test/auth_test.go
new file mode 100644
index 0000000..5ebc713
--- /dev/null
+++ b/control-plane/api/internal/test/auth_test.go
@@ -0,0 +1,93 @@
+package apitest
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+ "github.com/omnex/control-plane/api/internal/session"
+)
+
+func TestHashAndVerifyPassword(t *testing.T) {
+ hash, err := auth.HashPassword("s3cret-password")
+ if err != nil {
+ t.Fatalf("hash: %v", err)
+ }
+ if hash == "s3cret-password" {
+ t.Fatal("le mot de passe ne doit pas être stocké en clair")
+ }
+ ok, err := auth.VerifyPassword("s3cret-password", hash)
+ if err != nil || !ok {
+ t.Fatalf("verify bon mdp: ok=%v err=%v", ok, err)
+ }
+ if ok, _ := auth.VerifyPassword("mauvais", hash); ok {
+ t.Fatal("un mauvais mot de passe ne doit pas passer")
+ }
+}
+
+func TestHashUniqueSalt(t *testing.T) {
+ h1, _ := auth.HashPassword("same")
+ h2, _ := auth.HashPassword("same")
+ if h1 == h2 {
+ t.Fatal("deux hash du même mdp doivent différer (salt aléatoire)")
+ }
+}
+
+func TestVerifyRejectsMalformed(t *testing.T) {
+ if _, err := auth.VerifyPassword("x", "pas-un-hash"); err == nil {
+ t.Fatal("un hash malformé doit être rejeté")
+ }
+}
+
+func TestJWTIssueVerify(t *testing.T) {
+ iss := auth.NewIssuer([]byte(testSecret), time.Minute)
+ tok, err := iss.Issue("user-1", auth.RoleClient, "sid-123")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+ claims, err := iss.Verify(tok)
+ if err != nil {
+ t.Fatalf("verify: %v", err)
+ }
+ if claims.Subject != "user-1" || claims.Role != auth.RoleClient || claims.SessionID != "sid-123" {
+ t.Fatalf("claims inattendus: %+v", claims)
+ }
+}
+
+// Sécurité : token signé avec une autre clé rejeté.
+func TestJWTRejectsWrongKey(t *testing.T) {
+ a := auth.NewIssuer([]byte(testSecret), time.Minute)
+ b := auth.NewIssuer([]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), time.Minute)
+ tok, _ := a.Issue("u", auth.RoleClient, "sid")
+ if _, err := b.Verify(tok); err == nil {
+ t.Fatal("un token d'une autre clé ne doit pas être accepté")
+ }
+}
+
+// Sécurité : token expiré rejeté.
+func TestJWTRejectsExpired(t *testing.T) {
+ iss := auth.NewIssuer([]byte(testSecret), -time.Minute)
+ tok, _ := iss.Issue("u", auth.RoleClient, "sid")
+ if _, err := iss.Verify(tok); err == nil {
+ t.Fatal("un token expiré ne doit pas être accepté")
+ }
+}
+
+// Session Redis(mem) : create → get → delete.
+func TestSessionManagerLifecycle(t *testing.T) {
+ mgr := session.NewMemManager(time.Hour)
+ sid, err := mgr.Create(context.Background(), session.Session{UserID: "u1", Role: "sales"})
+ if err != nil || sid == "" {
+ t.Fatalf("create: sid=%q err=%v", sid, err)
+ }
+ if _, found, _ := mgr.Get(context.Background(), sid); !found {
+ t.Fatal("session attendue présente")
+ }
+ if err := mgr.Delete(context.Background(), sid); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, found, _ := mgr.Get(context.Background(), sid); found {
+ t.Fatal("session ne doit plus exister après delete")
+ }
+}
diff --git a/control-plane/api/internal/test/demos_test.go b/control-plane/api/internal/test/demos_test.go
new file mode 100644
index 0000000..1c82123
--- /dev/null
+++ b/control-plane/api/internal/test/demos_test.go
@@ -0,0 +1,187 @@
+package apitest
+
+import (
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/demos"
+)
+
+func newSvc() *demos.Service {
+ return demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{})
+}
+
+// Feature : une démo créée est en provisioning, TTL ~30 j, URL et namespace posés.
+func TestDemoCreate(t *testing.T) {
+ svc := newSvc()
+ d, err := svc.Create("")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if d.Status != demos.StatusProvisioning {
+ t.Fatalf("statut attendu provisioning, reçu %s", d.Status)
+ }
+ if d.Namespace == "" || d.URL == "" {
+ t.Fatalf("namespace/url manquants: %+v", d)
+ }
+ ttl := d.ExpiresAt.Sub(d.CreatedAt)
+ if ttl < demos.TTL-time.Minute || ttl > demos.TTL+time.Minute {
+ t.Fatalf("TTL attendu ~30j, reçu %s", ttl)
+ }
+}
+
+// Feature : borrow/return du pool cohérents.
+func TestDemoPoolBorrowReturn(t *testing.T) {
+ pool := demos.NewMemPool()
+ svc := demos.NewService(demos.NewMemStore(), pool, demos.NoopProvisioner{}, demos.Config{})
+
+ before, _ := pool.FreeCount()
+ if before[demos.ServiceTelegram] != demos.PoolSizePerService {
+ t.Fatalf("pool initial telegram attendu %d, reçu %d", demos.PoolSizePerService, before[demos.ServiceTelegram])
+ }
+
+ d, err := svc.Create("")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ after, _ := pool.FreeCount()
+ if after[demos.ServiceTelegram] != demos.PoolSizePerService-1 {
+ t.Fatalf("après emprunt telegram attendu %d, reçu %d", demos.PoolSizePerService-1, after[demos.ServiceTelegram])
+ }
+
+ if _, err := svc.Delete(d.ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ back, _ := pool.FreeCount()
+ if back[demos.ServiceTelegram] != demos.PoolSizePerService {
+ t.Fatalf("après restitution telegram attendu %d, reçu %d", demos.PoolSizePerService, back[demos.ServiceTelegram])
+ }
+}
+
+// Feature : capacité — la 6ᵉ démo est refusée (5 slots de pool).
+func TestDemoCapacityReached(t *testing.T) {
+ svc := newSvc()
+ for i := 0; i < demos.MaxConcurrentDemos; i++ {
+ if _, err := svc.Create(""); err != nil {
+ t.Fatalf("create %d: %v", i, err)
+ }
+ }
+ if _, err := svc.Create(""); !errors.Is(err, demos.ErrCapacityReached) {
+ t.Fatalf("6ᵉ démo : attendu ErrCapacityReached, reçu %v", err)
+ }
+}
+
+// Feature : la capacité se libère après destruction.
+func TestDemoCapacityFreesAfterDelete(t *testing.T) {
+ svc := newSvc()
+ var first demos.Demo
+ for i := 0; i < demos.MaxConcurrentDemos; i++ {
+ d, err := svc.Create("")
+ if err != nil {
+ t.Fatalf("create %d: %v", i, err)
+ }
+ if i == 0 {
+ first = d
+ }
+ }
+ if _, err := svc.Delete(first.ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := svc.Create(""); err != nil {
+ t.Fatalf("après libération : create doit réussir, reçu %v", err)
+ }
+}
+
+// Feature : extend prolonge de 30 j.
+func TestDemoExtend(t *testing.T) {
+ svc := newSvc()
+ d, _ := svc.Create("")
+ ext, err := svc.Extend(d.ID)
+ if err != nil {
+ t.Fatalf("extend: %v", err)
+ }
+ if !ext.ExpiresAt.After(d.ExpiresAt) {
+ t.Fatalf("extend doit repousser l'échéance: avant=%s après=%s", d.ExpiresAt, ext.ExpiresAt)
+ }
+}
+
+// Feature : delete idempotent + not found.
+func TestDemoDeleteNotFound(t *testing.T) {
+ svc := newSvc()
+ if _, err := svc.Delete("inconnu"); !errors.Is(err, demos.ErrNotFound) {
+ t.Fatalf("attendu ErrNotFound, reçu %v", err)
+ }
+}
+
+// --- Tests HTTP (routes protégées) ---
+
+func TestDemosEndpointRequiresAuth(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/demos", "", gin.H{})
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("attendu 401 sans token, reçu %d", w.Code)
+ }
+}
+
+// Sécurité : un commercial (sales/« client ») ne peut PAS provisionner de démo.
+func TestDemosForbiddenForSales(t *testing.T) {
+ e := newTestEnv(t)
+ sales := e.token(t) // rôle sales
+ for _, tc := range []struct{ method, path string }{
+ {http.MethodPost, "/api/v1/demos"},
+ {http.MethodGet, "/api/v1/demos"},
+ {http.MethodDelete, "/api/v1/demos/x"},
+ {http.MethodPost, "/api/v1/demos/x/extend"},
+ } {
+ w := e.do(tc.method, tc.path, sales, gin.H{})
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("%s %s : attendu 403 pour un commercial, reçu %d", tc.method, tc.path, w.Code)
+ }
+ }
+}
+
+func TestDemosCreateAndGet(t *testing.T) {
+ e := newTestEnv(t)
+ tok := e.adminToken(t)
+
+ w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{})
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("attendu 202, reçu %d: %s", w.Code, w.Body)
+ }
+ created := decode[demos.Demo](t, w)
+
+ g := e.do(http.MethodGet, "/api/v1/demos/"+created.ID, tok, nil)
+ if g.Code != http.StatusOK {
+ t.Fatalf("get attendu 200, reçu %d", g.Code)
+ }
+ if u := decode[demos.Demo](t, g); u.ID != created.ID {
+ t.Fatalf("id incohérent: %s != %s", u.ID, created.ID)
+ }
+}
+
+func TestDemosGetUnknown404(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodGet, "/api/v1/demos/inconnu", e.adminToken(t), nil)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("attendu 404, reçu %d", w.Code)
+ }
+}
+
+// Capacité via HTTP : la 6ᵉ création renvoie 409.
+func TestDemosCapacityHTTP(t *testing.T) {
+ e := newTestEnv(t)
+ tok := e.adminToken(t)
+ for i := 0; i < demos.MaxConcurrentDemos; i++ {
+ if w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{}); w.Code != http.StatusAccepted {
+ t.Fatalf("create %d attendu 202, reçu %d", i, w.Code)
+ }
+ }
+ w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{})
+ if w.Code != http.StatusConflict {
+ t.Fatalf("6ᵉ démo attendu 409, reçu %d", w.Code)
+ }
+}
diff --git a/control-plane/api/internal/test/helpers_test.go b/control-plane/api/internal/test/helpers_test.go
new file mode 100644
index 0000000..00a4265
--- /dev/null
+++ b/control-plane/api/internal/test/helpers_test.go
@@ -0,0 +1,111 @@
+// Package apitest regroupe les tests unitaires et de sécurité de l'API Omnex
+// (tests en boîte noire, isolés du code de production dans un dossier dédié).
+package apitest
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/omnex/control-plane/api/internal/auth"
+ "github.com/omnex/control-plane/api/internal/config"
+ "github.com/omnex/control-plane/api/internal/demos"
+ "github.com/omnex/control-plane/api/internal/leads"
+ "github.com/omnex/control-plane/api/internal/router"
+ "github.com/omnex/control-plane/api/internal/session"
+)
+
+const testSecret = "01234567890123456789012345678901"
+
+// seedUsers : UserStore mémoire pour les tests.
+type seedUsers map[string]auth.User
+
+func (m seedUsers) ByUsername(username string) (auth.User, bool) { u, ok := m[username]; return u, ok }
+
+func (m seedUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) {
+ u := auth.User{ID: "u-" + username, Username: username, PasswordHash: passwordHash, Role: role}
+ m[username] = u
+ return u, nil
+}
+
+// testEnv : dépendances assemblées pour un test HTTP.
+type testEnv struct {
+ engine *gin.Engine
+ issuer *auth.Issuer
+ sessions session.Manager
+}
+
+func newTestEnv(t *testing.T) testEnv {
+ return newTestEnvOpts(t)
+}
+
+// newTestEnvOpts permet de configurer un code d'inscription.
+func newTestEnvOpts(t *testing.T) testEnv {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ iss := auth.NewIssuer([]byte(testSecret), time.Minute)
+ sessions := session.NewMemManager(time.Hour)
+
+ hash, _ := auth.HashPassword("correct-horse")
+ users := seedUsers{"sales": {ID: "u1", Username: "sales", PasswordHash: hash, Role: auth.RoleClient}}
+
+ demoSvc := demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{})
+
+ deps := router.Deps{
+ Cfg: config.Config{Env: "test", AllowedOrigins: []string{"http://localhost:5173"}},
+ Issuer: iss,
+ Sessions: sessions,
+ AuthH: auth.NewHandler(users, sessions, iss, false),
+ LeadsH: leads.NewHandler(leads.NewMemStore()),
+ DemosH: demos.NewHandler(demoSvc),
+ }
+ return testEnv{engine: router.New(deps), issuer: iss, sessions: sessions}
+}
+
+// token : JWT commercial (rôle sales) adossé à une vraie session Redis(mem).
+func (e testEnv) token(t *testing.T) string { return e.tokenAs(t, auth.RoleClient) }
+
+// adminToken : JWT administrateur.
+func (e testEnv) adminToken(t *testing.T) string { return e.tokenAs(t, auth.RoleAdmin) }
+
+func (e testEnv) tokenAs(t *testing.T, role auth.Role) string {
+ t.Helper()
+ sid, err := e.sessions.Create(context.Background(), session.Session{UserID: "u1", Role: string(role)})
+ if err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ tok, err := e.issuer.Issue("u1", role, sid)
+ if err != nil {
+ t.Fatalf("issue token: %v", err)
+ }
+ return tok
+}
+
+func (e testEnv) do(method, path, token string, body any) *httptest.ResponseRecorder {
+ var buf bytes.Buffer
+ if body != nil {
+ _ = json.NewEncoder(&buf).Encode(body)
+ }
+ req := httptest.NewRequest(method, path, &buf)
+ req.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ w := httptest.NewRecorder()
+ e.engine.ServeHTTP(w, req)
+ return w
+}
+
+func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
+ t.Helper()
+ var v T
+ if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
+ t.Fatalf("decode: %v — body=%s", err, w.Body)
+ }
+ return v
+}
diff --git a/control-plane/api/internal/test/http_test.go b/control-plane/api/internal/test/http_test.go
new file mode 100644
index 0000000..ba08061
--- /dev/null
+++ b/control-plane/api/internal/test/http_test.go
@@ -0,0 +1,142 @@
+package apitest
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestLoginSuccess(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"})
+ if w.Code != http.StatusOK {
+ t.Fatalf("login attendu 200, reçu %d: %s", w.Code, w.Body)
+ }
+ if tok := decode[map[string]any](t, w)["token"]; tok == nil || tok == "" {
+ t.Fatal("login doit renvoyer un token")
+ }
+ // Le cookie de session httpOnly doit être posé.
+ if len(w.Result().Cookies()) == 0 {
+ t.Fatal("login doit poser un cookie de session")
+ }
+}
+
+// Inscription : crée un compte et connecte directement (201 + token).
+func TestRegisterCreatesAccountAndSession(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "newsales", "password": "s3cure-pass-1"})
+ if w.Code != http.StatusCreated {
+ t.Fatalf("register attendu 201, reçu %d: %s", w.Code, w.Body)
+ }
+ tok, _ := decode[map[string]any](t, w)["token"].(string)
+ if tok == "" {
+ t.Fatal("register doit renvoyer un token")
+ }
+ // Le token doit donner accès aux routes protégées.
+ if g := e.do(http.MethodGet, "/api/v1/leads", tok, nil); g.Code != http.StatusOK {
+ t.Fatalf("accès après inscription attendu 200, reçu %d", g.Code)
+ }
+}
+
+// Inscription : username déjà pris => 409.
+func TestRegisterDuplicateUsername(t *testing.T) {
+ e := newTestEnv(t)
+ // "sales" existe déjà (seedé).
+ w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "sales", "password": "s3cure-pass-1"})
+ if w.Code != http.StatusConflict {
+ t.Fatalf("attendu 409, reçu %d", w.Code)
+ }
+}
+
+// Sécurité : mot de passe trop court rejeté à la validation.
+func TestRegisterRejectsWeakPassword(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "weakling", "password": "short"})
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("attendu 400, reçu %d", w.Code)
+ }
+}
+
+// Sécurité : après logout, le JWT (même valide) est refusé — session Redis révoquée.
+func TestLogoutRevokesSession(t *testing.T) {
+ e := newTestEnv(t)
+ login := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"})
+ tok, _ := decode[map[string]any](t, login)["token"].(string)
+
+ if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusOK {
+ t.Fatalf("accès avant logout attendu 200, reçu %d", w.Code)
+ }
+ if w := e.do(http.MethodPost, "/api/v1/auth/logout", tok, nil); w.Code != http.StatusOK {
+ t.Fatalf("logout attendu 200, reçu %d", w.Code)
+ }
+ // Même token, mais session supprimée => 401.
+ if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusUnauthorized {
+ t.Fatalf("après logout attendu 401, reçu %d", w.Code)
+ }
+}
+
+func TestLoginWrongPassword(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "wrong-password"})
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("attendu 401, reçu %d", w.Code)
+ }
+}
+
+// Sécurité : pas d'énumération — même réponse pour un utilisateur inconnu.
+func TestLoginUnknownUserSameResponse(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "ghost", "password": "whatever8"})
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("attendu 401 (pas d'énumération), reçu %d", w.Code)
+ }
+}
+
+// Sécurité : un username avec caractères spéciaux (tentative d'injection) rejeté à la validation.
+func TestLoginRejectsNonAlnumUsername(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales' OR '1'='1", "password": "whatever8"})
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("attendu 400 (validation), reçu %d", w.Code)
+ }
+}
+
+func TestLeadsListRequiresAuth(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodGet, "/api/v1/leads", "", nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("attendu 401 sans token, reçu %d", w.Code)
+ }
+}
+
+func TestLeadsListRejectsForgedToken(t *testing.T) {
+ e := newTestEnv(t)
+ w := e.do(http.MethodGet, "/api/v1/leads", "eyJ.forged.token", nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("attendu 401 token forgé, reçu %d", w.Code)
+ }
+}
+
+// Feature lead : création publique + XSS stocké neutralisé.
+func TestCreateLeadSanitizesXSS(t *testing.T) {
+ e := newTestEnv(t)
+ payload := gin.H{"company": "", "message": "hi"}
+ w := e.do(http.MethodPost, "/api/v1/leads", "", payload)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("attendu 201, reçu %d: %s", w.Code, w.Body)
+ }
+ list := e.do(http.MethodGet, "/api/v1/leads", e.token(t), nil)
+ if bytes.Contains(list.Body.Bytes(), []byte("