diff --git a/.gitignore b/.gitignore
index bbe6e5f..7cee59f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,3 +97,8 @@ loremind-docs/
# Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================
docker-compose.override.yml
+
+# ============================================================================
+# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
+# ============================================================================
+relay/
diff --git a/core/pom.xml b/core/pom.xml
index c5c74de..cc8897a 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.7.2
+ 0.8.0LoreMind CoreBackend Core - Architecture Hexagonale
@@ -83,6 +83,19 @@
minio8.5.11
+
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+ 9.40
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.78.1
+
diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java
index 5ad7700..3aa11a0 100644
--- a/core/src/main/java/com/loremind/LoreMindApplication.java
+++ b/core/src/main/java/com/loremind/LoreMindApplication.java
@@ -2,12 +2,14 @@ package com.loremind;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application.
*/
@SpringBootApplication
+@EnableScheduling
public class LoreMindApplication {
public static void main(String[] args) {
diff --git a/core/src/main/java/com/loremind/application/licensing/LicenseService.java b/core/src/main/java/com/loremind/application/licensing/LicenseService.java
new file mode 100644
index 0000000..0a437a5
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/licensing/LicenseService.java
@@ -0,0 +1,261 @@
+package com.loremind.application.licensing;
+
+import com.loremind.domain.licensing.License;
+import com.loremind.domain.licensing.LicenseClaims;
+import com.loremind.domain.licensing.LicenseSnapshot;
+import com.loremind.domain.licensing.LicenseStatus;
+import com.loremind.domain.licensing.RegistryCredentials;
+import com.loremind.domain.licensing.ports.JwtVerifier;
+import com.loremind.domain.licensing.ports.LicenseRelay;
+import com.loremind.domain.licensing.ports.LicenseRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Service application pour la gestion de la licence Patreon.
+ *
+ * Responsabilites :
+ *
+ *
Installer un nouveau JWT recu du relais (apres OAuth utilisateur)
+ *
Calculer le {@link LicenseStatus} courant en respectant la grace period
+ *
Renouveler le JWT avant expiration en appelant le relais
+ *
Activer/desactiver le toggle "canal beta" cote utilisateur
+ *
Distribuer les credentials registry pour le pull beta
+ *
+ */
+@Service
+public class LicenseService {
+
+ private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
+
+ private final LicenseRepository repository;
+ private final JwtVerifier jwtVerifier;
+ private final LicenseRelay relay;
+ private final long gracePeriodSeconds;
+ private final long refreshBeforeExpirySeconds;
+
+ public LicenseService(
+ LicenseRepository repository,
+ JwtVerifier jwtVerifier,
+ LicenseRelay relay,
+ @Value("${licensing.grace-period-days:14}") int gracePeriodDays,
+ @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
+ this.repository = repository;
+ this.jwtVerifier = jwtVerifier;
+ this.relay = relay;
+ this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
+ this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
+ }
+
+ /**
+ * @return true si le verifier est configure (cle publique presente).
+ * L'UI peut masquer toute la section Patreon si false.
+ */
+ public boolean isLicensingEnabled() {
+ return jwtVerifier.isConfigured();
+ }
+
+ /**
+ * Genere ou retourne l'instance_id stable de cette installation.
+ * Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
+ * (sera persiste a la prochaine connexion).
+ */
+ public String getOrCreateInstanceId() {
+ return repository.findCurrent()
+ .map(License::getInstanceId)
+ .orElseGet(() -> "li-" + UUID.randomUUID());
+ }
+
+ /**
+ * Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
+ */
+ public String buildConnectUrl() {
+ return relay.buildConnectUrl(getOrCreateInstanceId());
+ }
+
+ /**
+ * Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
+ * recu via deep-link). Verifie la signature, extrait les claims, persiste.
+ */
+ public LicenseSnapshot installToken(String rawJwt) throws InstallException {
+ if (!jwtVerifier.isConfigured()) {
+ throw new InstallException("Licensing feature not enabled (no public key configured)");
+ }
+ LicenseClaims claims;
+ try {
+ claims = jwtVerifier.verify(rawJwt);
+ } catch (JwtVerifier.JwtVerificationException e) {
+ throw new InstallException("Invalid JWT: " + e.getMessage());
+ }
+
+ Instant now = Instant.now();
+ if (claims.expiresAt().isBefore(now)) {
+ throw new InstallException("JWT already expired");
+ }
+
+ Optional existing = repository.findCurrent();
+ License toSave = License.builder()
+ .id("current")
+ .rawJwt(rawJwt)
+ .patreonUserId(claims.subject())
+ .tierId(claims.tierId())
+ .instanceId(claims.instanceId())
+ .issuedAt(claims.issuedAt())
+ .expiresAt(claims.expiresAt())
+ .lastRefreshAttemptAt(now)
+ .lastRefreshSucceeded(true)
+ // Au premier install, on active le canal beta par defaut.
+ // Sur reinstall apres deconnexion, on respecte la valeur precedente.
+ .betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
+ .createdAt(existing.map(License::getCreatedAt).orElse(now))
+ .build();
+
+ License saved = repository.save(toSave);
+ log.info("Patreon license installed for user={} tier={} expires={}",
+ saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
+ return snapshotOf(saved, now);
+ }
+
+ /**
+ * Etat courant de la licence pour exposition UI / decision technique.
+ */
+ public LicenseSnapshot getCurrentSnapshot() {
+ Optional opt = repository.findCurrent();
+ if (opt.isEmpty()) return LicenseSnapshot.none();
+ return snapshotOf(opt.get(), Instant.now());
+ }
+
+ /**
+ * Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
+ */
+ public void disconnect() {
+ repository.deleteCurrent();
+ log.info("Patreon license removed (user disconnect)");
+ }
+
+ /**
+ * Active ou desactive le canal beta. Necessite une licence valide ou en grace.
+ */
+ public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
+ License current = repository.findCurrent()
+ .orElseThrow(() -> new IllegalStateException("No license installed"));
+ current.setBetaChannelEnabled(enabled);
+ License saved = repository.save(current);
+ return snapshotOf(saved, Instant.now());
+ }
+
+ /**
+ * Tente un refresh si la licence est proche de l'expiration. Idempotent.
+ * Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
+ *
+ * @return true si un refresh a ete tente (avec ou sans succes)
+ */
+ public boolean refreshIfNeeded() {
+ Optional opt = repository.findCurrent();
+ if (opt.isEmpty()) return false;
+ License current = opt.get();
+ Instant now = Instant.now();
+ long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
+ if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
+ return false;
+ }
+ return doRefresh(current, now);
+ }
+
+ /**
+ * Force un refresh immediat (bouton UI "Reessayer maintenant").
+ */
+ public boolean forceRefresh() {
+ return repository.findCurrent()
+ .map(license -> doRefresh(license, Instant.now()))
+ .orElse(false);
+ }
+
+ private boolean doRefresh(License current, Instant now) {
+ log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
+ try {
+ String newJwt = relay.refreshToken(current.getRawJwt());
+ LicenseClaims claims = jwtVerifier.verify(newJwt);
+
+ current.setRawJwt(newJwt);
+ current.setIssuedAt(claims.issuedAt());
+ current.setExpiresAt(claims.expiresAt());
+ current.setTierId(claims.tierId());
+ current.setLastRefreshAttemptAt(now);
+ current.setLastRefreshSucceeded(true);
+ repository.save(current);
+ log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
+ return true;
+ } catch (LicenseRelay.RelayException e) {
+ current.setLastRefreshAttemptAt(now);
+ current.setLastRefreshSucceeded(false);
+ repository.save(current);
+ if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
+ log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
+ } else {
+ log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
+ }
+ return true;
+ } catch (JwtVerifier.JwtVerificationException e) {
+ current.setLastRefreshAttemptAt(now);
+ current.setLastRefreshSucceeded(false);
+ repository.save(current);
+ log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
+ return true;
+ }
+ }
+
+ /**
+ * Recupere les credentials registry pour pull du canal beta.
+ * @return empty si pas de licence valide ou relais en echec
+ */
+ public Optional fetchRegistryCredentials() {
+ LicenseSnapshot snap = getCurrentSnapshot();
+ if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
+ return Optional.empty();
+ }
+ License current = repository.findCurrent().orElse(null);
+ if (current == null) return Optional.empty();
+ try {
+ return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
+ } catch (LicenseRelay.RelayException e) {
+ log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private LicenseSnapshot snapshotOf(License l, Instant now) {
+ LicenseStatus status = computeStatus(l, now);
+ return new LicenseSnapshot(
+ status,
+ l.getPatreonUserId(),
+ l.getTierId(),
+ l.getInstanceId(),
+ l.getExpiresAt(),
+ l.getLastRefreshAttemptAt(),
+ l.isLastRefreshSucceeded(),
+ l.isBetaChannelEnabled()
+ );
+ }
+
+ private LicenseStatus computeStatus(License l, Instant now) {
+ if (l.getExpiresAt() == null) return LicenseStatus.NONE;
+ if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
+ long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
+ if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
+ return LicenseStatus.EXPIRED;
+ }
+
+ public static class InstallException extends Exception {
+ public InstallException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/License.java b/core/src/main/java/com/loremind/domain/licensing/License.java
new file mode 100644
index 0000000..5988386
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/License.java
@@ -0,0 +1,48 @@
+package com.loremind.domain.licensing;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * Licence Patreon installee dans cette instance LoreMind.
+ *
+ * Singleton (une seule licence par instance, identifiee logiquement par
+ * {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
+ * + les claims extraits a la verification, plus l'etat operationnel
+ * (derniere tentative de refresh, succes/echec).
+ *
+ * Note securite : {@link #rawJwt} est stocke tel quel ; sa signature
+ * Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
+ * supplementaire — un attaquant qui a acces a la base a deja l'instance,
+ * et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
+ */
+@Data
+@Builder
+public class License {
+
+ private String id;
+
+ private String rawJwt;
+
+ private String patreonUserId;
+
+ private String tierId;
+
+ private String instanceId;
+
+ private Instant issuedAt;
+
+ private Instant expiresAt;
+
+ private Instant lastRefreshAttemptAt;
+
+ private boolean lastRefreshSucceeded;
+
+ private boolean betaChannelEnabled;
+
+ private Instant createdAt;
+
+ private Instant updatedAt;
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java b/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java
new file mode 100644
index 0000000..423097e
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java
@@ -0,0 +1,15 @@
+package com.loremind.domain.licensing;
+
+import java.time.Instant;
+
+/**
+ * Claims extraits d'un JWT licence apres verification de signature.
+ * Immuable.
+ */
+public record LicenseClaims(
+ String subject,
+ String tierId,
+ String instanceId,
+ Instant issuedAt,
+ Instant expiresAt
+) {}
diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java b/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java
new file mode 100644
index 0000000..d79b90c
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java
@@ -0,0 +1,23 @@
+package com.loremind.domain.licensing;
+
+import java.time.Instant;
+
+/**
+ * Vue immuable de la licence pour exposition vers les couches superieures.
+ * Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
+ * a un instant donne sans muter l'entite.
+ */
+public record LicenseSnapshot(
+ LicenseStatus status,
+ String patreonUserId,
+ String tierId,
+ String instanceId,
+ Instant expiresAt,
+ Instant lastRefreshAttemptAt,
+ boolean lastRefreshSucceeded,
+ boolean betaChannelEnabled
+) {
+ public static LicenseSnapshot none() {
+ return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java b/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java
new file mode 100644
index 0000000..d410a14
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java
@@ -0,0 +1,23 @@
+package com.loremind.domain.licensing;
+
+/**
+ * Etat operationnel de la licence vis-a-vis de l'acces beta.
+ *
+ * Calcule a partir de la presence de licence + son JWT exp + grace period.
+ *
+ *
{@link #NONE} : aucune licence installee
+ *
{@link #VALID} : JWT non expire, acces beta autorise
+ *
{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
+ * acces beta toujours autorise, l'UI doit avertir
+ *
{@link #EXPIRED} : au-dela de la grace period, acces beta refuse
+ *
{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
+ * signature invalide, claims malformes) — traite comme NONE pour la securite
+ *
+ */
+public enum LicenseStatus {
+ NONE,
+ VALID,
+ GRACE,
+ EXPIRED,
+ UNVERIFIABLE
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java b/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java
new file mode 100644
index 0000000..72a38c4
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java
@@ -0,0 +1,18 @@
+package com.loremind.domain.licensing;
+
+import java.time.Instant;
+
+/**
+ * Credentials de pull pour un registry Docker, distribues par le relais
+ * apres verification d'un JWT licence valide.
+ *
+ * {@code expiresAt} peut etre {@code null} si le credential est statique
+ * (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
+ * de nouveaux credentials avant cette date.
+ */
+public record RegistryCredentials(
+ String registry,
+ String username,
+ String password,
+ Instant expiresAt
+) {}
diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java b/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java
new file mode 100644
index 0000000..c750cfa
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java
@@ -0,0 +1,34 @@
+package com.loremind.domain.licensing.ports;
+
+import com.loremind.domain.licensing.RegistryCredentials;
+
+import java.io.IOException;
+
+/**
+ * Port de sortie : ecriture du docker config.json partage avec Watchtower.
+ *
+ * Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
+ * lors du pull des images du canal beta. Volume Docker {@code docker-config}
+ * monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
+ * {@code DOCKER_CONFIG}).
+ */
+public interface DockerConfigWriter {
+
+ /**
+ * Ecrit ou met a jour les credentials pour le registry indique.
+ * Cree le fichier s'il n'existe pas, conserve les autres registries deja
+ * presents (en theorie : aucun, mais defensif).
+ */
+ void writeCredentials(RegistryCredentials credentials) throws IOException;
+
+ /**
+ * Supprime le fichier de credentials. Appele quand la licence est invalidee
+ * ou que le toggle beta passe a OFF.
+ */
+ void clear() throws IOException;
+
+ /**
+ * @return true si le fichier de creds existe actuellement.
+ */
+ boolean isPresent();
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java b/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java
new file mode 100644
index 0000000..f8d800f
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java
@@ -0,0 +1,34 @@
+package com.loremind.domain.licensing.ports;
+
+import com.loremind.domain.licensing.LicenseClaims;
+
+/**
+ * Port de sortie : verification de signature et extraction des claims
+ * d'un JWT emis par le relais.
+ *
+ * Implemente cote infrastructure avec la cle publique Ed25519 embarquee
+ * (SPKI PEM via configuration {@code licensing.jwt.public-key}).
+ */
+public interface JwtVerifier {
+
+ /**
+ * Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
+ * @throws JwtVerificationException si la signature est invalide ou les claims malformes
+ */
+ LicenseClaims verify(String rawJwt) throws JwtVerificationException;
+
+ /**
+ * @return true si la cle publique est configuree et utilisable.
+ * Permet a l'application de masquer la feature licensing si pas configuree.
+ */
+ boolean isConfigured();
+
+ class JwtVerificationException extends Exception {
+ public JwtVerificationException(String message) {
+ super(message);
+ }
+ public JwtVerificationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java
new file mode 100644
index 0000000..f9387f5
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java
@@ -0,0 +1,59 @@
+package com.loremind.domain.licensing.ports;
+
+import com.loremind.domain.licensing.RegistryCredentials;
+
+/**
+ * Port de sortie vers le service relais OAuth Patreon.
+ * Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
+ */
+public interface LicenseRelay {
+
+ /**
+ * Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
+ */
+ String buildConnectUrl(String instanceId);
+
+ /**
+ * Demande au relais de renouveler un JWT existant. Le relais re-verifie
+ * le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
+ * actif, ou leve {@link RelayException} sinon.
+ */
+ String refreshToken(String currentJwt) throws RelayException;
+
+ /**
+ * Demande au relais les credentials de pull du registry beta.
+ */
+ RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
+
+ /**
+ * Erreurs distinctes emises par le relais. Permet au service application
+ * de differencier "tier expire" (action utilisateur) de "relais down"
+ * (action transitoire, garde la grace period).
+ */
+ class RelayException extends Exception {
+ private final RelayErrorKind kind;
+
+ public RelayException(RelayErrorKind kind, String message) {
+ super(message);
+ this.kind = kind;
+ }
+
+ public RelayException(RelayErrorKind kind, String message, Throwable cause) {
+ super(message, cause);
+ this.kind = kind;
+ }
+
+ public RelayErrorKind getKind() {
+ return kind;
+ }
+ }
+
+ enum RelayErrorKind {
+ /** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
+ REJECTED,
+ /** Le relais a renvoye un JWT mais il est invalide / non parsable. */
+ BAD_RESPONSE,
+ /** Le relais est injoignable / 5xx / timeout. */
+ TRANSIENT
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java
new file mode 100644
index 0000000..d760868
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java
@@ -0,0 +1,19 @@
+package com.loremind.domain.licensing.ports;
+
+import com.loremind.domain.licensing.License;
+
+import java.util.Optional;
+
+/**
+ * Port de sortie pour la persistance de la licence installee.
+ *
+ * Une seule licence par instance ({@code id = "current"} par convention).
+ */
+public interface LicenseRepository {
+
+ Optional findCurrent();
+
+ License save(License license);
+
+ void deleteCurrent();
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java
new file mode 100644
index 0000000..3d2fa08
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java
@@ -0,0 +1,111 @@
+package com.loremind.infrastructure.licensing;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.loremind.domain.licensing.RegistryCredentials;
+import com.loremind.domain.licensing.ports.DockerConfigWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Base64;
+
+/**
+ * Implementation : ecriture du fichier {@code config.json} au format Docker
+ * standard, dans un volume partage avec Watchtower.
+ *
renouvelle le JWT licence via le relais avant expiration (J-2)
+ *
met a jour les credentials registry GHCR pour Watchtower
+ * (volume partage docker-config) tant que le canal beta est ON
+ *
nettoie les credentials si la licence est invalidee ou le toggle OFF
+ *
+ * Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
+ * la plupart du temps.
+ */
+@Component
+public class LicenseRefreshDaemon {
+
+ private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
+
+ /** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
+ private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
+ /** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
+ private static final long INITIAL_DELAY_MS = 30_000L;
+
+ private final LicenseService licenseService;
+ private final DockerConfigWriter dockerConfigWriter;
+
+ public LicenseRefreshDaemon(LicenseService licenseService,
+ DockerConfigWriter dockerConfigWriter) {
+ this.licenseService = licenseService;
+ this.dockerConfigWriter = dockerConfigWriter;
+ }
+
+ @Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
+ public void tick() {
+ if (!licenseService.isLicensingEnabled()) {
+ return;
+ }
+ try {
+ licenseService.refreshIfNeeded();
+ syncDockerConfig();
+ } catch (Exception e) {
+ log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Aligne le fichier docker config avec l'etat de la licence et le toggle :
+ *