diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java
index 926c2a52..5d6bb834 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java
@@ -1,22 +1,48 @@
package com.cameleer.server.app.config;
+import com.cameleer.server.app.license.LicenseRepository;
+import com.cameleer.server.app.license.LicenseService;
+import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
+import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.Optional;
+/**
+ * License bean topology (4 beans, in dependency order):
+ *
+ *
+ * - {@link LicenseGate} — always present, mutated by {@link LicenseService}.
+ * - {@link LicenseValidator} — always present. When no public key is configured, returns an
+ * always-failing override so any loaded token routes through {@code install()} and is
+ * audited as INVALID rather than silently ignored.
+ * - {@link LicenseService} — single mediation point for install / replace / revalidate;
+ * audits + persists + publishes {@code LicenseChangedEvent}.
+ * - {@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the
+ * Spring context is ready. Resolution order: env var > license file > persisted DB row.
+ *
+ */
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
+ @Value("${cameleer.server.tenant.id:default}")
+ private String tenantId;
+
@Value("${cameleer.server.license.token:}")
private String licenseToken;
@@ -26,46 +52,79 @@ public class LicenseBeanConfig {
@Value("${cameleer.server.license.publickey:}")
private String licensePublicKey;
- @Value("${cameleer.server.tenant.id:default}")
- private String tenantId;
-
@Bean
public LicenseGate licenseGate() {
- LicenseGate gate = new LicenseGate();
-
- String token = resolveLicenseToken();
- if (token == null || token.isBlank()) {
- log.info("No license configured — running in open mode (all features enabled)");
- return gate;
- }
-
- if (licensePublicKey == null || licensePublicKey.isBlank()) {
- log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode.");
- return gate;
- }
-
- try {
- LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId);
- LicenseInfo info = validator.validate(token);
- gate.load(info);
- } catch (Exception e) {
- log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
- }
-
- return gate;
+ return new LicenseGate();
}
- private String resolveLicenseToken() {
- if (licenseToken != null && !licenseToken.isBlank()) {
- return licenseToken;
- }
- if (licenseFile != null && !licenseFile.isBlank()) {
+ @Bean
+ public LicenseValidator licenseValidator() {
+ if (licensePublicKey == null || licensePublicKey.isBlank()) {
+ log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID");
+ // Generate a throwaway, structurally-valid Ed25519 keypair just to satisfy the
+ // parent constructor's X.509 SubjectPublicKeyInfo decode + Ed25519 point validation.
+ // The overridden validate(...) always throws, so the dummy key is never used to
+ // verify anything — it only exists so the bean is constructable in misconfigured
+ // installs and any token that is loaded routes to INVALID via install()'s catch.
try {
- return Files.readString(Path.of(licenseFile)).trim();
+ KeyPair throwaway = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
+ String dummyPub = Base64.getEncoder().encodeToString(throwaway.getPublic().getEncoded());
+ return new LicenseValidator(dummyPub, tenantId) {
+ @Override
+ public LicenseInfo validate(String token) {
+ throw new IllegalStateException("license public key not configured");
+ }
+ };
} catch (Exception e) {
- log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
+ throw new IllegalStateException("Failed to construct fallback license validator", e);
}
}
- return null;
+ return new LicenseValidator(licensePublicKey, tenantId);
+ }
+
+ @Bean
+ public LicenseService licenseService(LicenseRepository repo,
+ LicenseGate gate,
+ LicenseValidator validator,
+ AuditService audit,
+ ApplicationEventPublisher events) {
+ return new LicenseService(tenantId, repo, gate, validator, audit, events);
+ }
+
+ @Bean
+ public LicenseBootLoader licenseBootLoader(LicenseService svc) {
+ return new LicenseBootLoader(svc, licenseToken, licenseFile);
+ }
+
+ /**
+ * {@code @PostConstruct} bridge that converts env-var/file values into the
+ * {@code Optional} pair {@link LicenseService#loadInitial} expects, so
+ * env-var, file, and DB paths share the same audit + event-publish code path.
+ */
+ public static class LicenseBootLoader {
+ private final LicenseService svc;
+ private final String envToken;
+ private final String filePath;
+
+ public LicenseBootLoader(LicenseService svc, String envToken, String filePath) {
+ this.svc = svc;
+ this.envToken = envToken;
+ this.filePath = filePath;
+ }
+
+ @PostConstruct
+ public void load() {
+ Optional env = (envToken != null && !envToken.isBlank())
+ ? Optional.of(envToken) : Optional.empty();
+ Optional file = Optional.empty();
+ if (filePath != null && !filePath.isBlank()) {
+ try {
+ file = Optional.of(Files.readString(Path.of(filePath)).trim());
+ } catch (Exception e) {
+ log.warn("Failed to read license file {}: {}", filePath, e.getMessage());
+ }
+ }
+ svc.loadInitial(env, file);
+ }
}
}