diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java
new file mode 100644
index 00000000..59df6a7a
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java
@@ -0,0 +1,12 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.core.license.LicenseInfo;
+import com.cameleer.server.core.license.LicenseState;
+
+import java.util.Objects;
+
+public record LicenseChangedEvent(LicenseState state, LicenseInfo current) {
+ public LicenseChangedEvent {
+ Objects.requireNonNull(state);
+ }
+}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java
new file mode 100644
index 00000000..7d3509af
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java
@@ -0,0 +1,133 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.core.admin.AuditCategory;
+import com.cameleer.server.core.admin.AuditResult;
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Single mediation point for license token install / replace / revalidate.
+ *
+ *
Audits under {@link AuditCategory#LICENSE}, persists to PostgreSQL via
+ * {@link LicenseRepository}, mutates the in-memory {@link LicenseGate}, and publishes a
+ * {@link LicenseChangedEvent} so downstream listeners (retention policy, license metrics,
+ * etc.) react uniformly to every state change.
+ */
+public class LicenseService {
+
+ private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
+
+ private final String tenantId;
+ private final LicenseRepository repo;
+ private final LicenseGate gate;
+ private final LicenseValidator validator;
+ private final AuditService audit;
+ private final ApplicationEventPublisher events;
+
+ public LicenseService(String tenantId, LicenseRepository repo, LicenseGate gate,
+ LicenseValidator validator, AuditService audit,
+ ApplicationEventPublisher events) {
+ this.tenantId = tenantId;
+ this.repo = repo;
+ this.gate = gate;
+ this.validator = validator;
+ this.audit = audit;
+ this.events = events;
+ }
+
+ /** Install a token from any source (env, file, api, db). */
+ public LicenseInfo install(String token, String installedBy, String source) {
+ LicenseInfo info;
+ try {
+ info = validator.validate(token);
+ } catch (Exception e) {
+ String reason = e.getMessage();
+ gate.markInvalid(reason);
+ Map detail = new LinkedHashMap<>();
+ detail.put("reason", reason);
+ detail.put("source", source);
+ audit.log(installedBy, "reject_license", AuditCategory.LICENSE,
+ tenantId, detail, AuditResult.FAILURE, null);
+ events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
+ throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e);
+ }
+
+ Optional existing = repo.findByTenantId(tenantId);
+ Instant now = Instant.now();
+ repo.upsert(new LicenseRecord(
+ tenantId, token, info.licenseId(),
+ now, installedBy, info.expiresAt(), now));
+ gate.load(info);
+
+ Map detail = new LinkedHashMap<>();
+ detail.put("licenseId", info.licenseId().toString());
+ detail.put("expiresAt", info.expiresAt().toString());
+ detail.put("installedBy", installedBy);
+ detail.put("source", source);
+ if (existing.isPresent()) {
+ detail.put("previousLicenseId", existing.get().licenseId().toString());
+ audit.log(installedBy, "replace_license", AuditCategory.LICENSE,
+ info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
+ } else {
+ audit.log(installedBy, "install_license", AuditCategory.LICENSE,
+ info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
+ }
+
+ events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
+ return info;
+ }
+
+ /** Boot-time load: prefer env/file overrides; falls back to DB; ABSENT if none. */
+ public void loadInitial(Optional envToken, Optional fileToken) {
+ if (envToken.isPresent()) {
+ try { install(envToken.get(), "system", "env"); return; }
+ catch (Exception e) { log.error("env-var license rejected: {}", e.getMessage()); }
+ }
+ if (fileToken.isPresent()) {
+ try { install(fileToken.get(), "system", "file"); return; }
+ catch (Exception e) { log.error("file license rejected: {}", e.getMessage()); }
+ }
+ Optional persisted = repo.findByTenantId(tenantId);
+ if (persisted.isPresent()) {
+ try { install(persisted.get().token(), persisted.get().installedBy(), "db"); }
+ catch (Exception e) { log.error("DB license rejected: {}", e.getMessage()); }
+ } else {
+ log.info("No license configured - running in default tier");
+ events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
+ }
+ }
+
+ /** Re-run validation against the persisted token (daily job). */
+ public void revalidate() {
+ Optional persisted = repo.findByTenantId(tenantId);
+ if (persisted.isEmpty()) return;
+ try {
+ LicenseInfo info = validator.validate(persisted.get().token());
+ repo.touchValidated(tenantId, Instant.now());
+ gate.load(info);
+ events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
+ } catch (Exception e) {
+ String reason = e.getMessage();
+ gate.markInvalid(reason);
+ Map detail = new LinkedHashMap<>();
+ detail.put("licenseId", persisted.get().licenseId().toString());
+ detail.put("reason", reason);
+ audit.log("system", "revalidate_license", AuditCategory.LICENSE,
+ persisted.get().licenseId().toString(), detail, AuditResult.FAILURE, null);
+ events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
+ log.error("Revalidation failed: {}", reason);
+ }
+ }
+
+ public String getTenantId() { return tenantId; }
+}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java
new file mode 100644
index 00000000..7f54da41
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java
@@ -0,0 +1,100 @@
+package com.cameleer.server.app.license;
+
+import com.cameleer.server.core.admin.AuditCategory;
+import com.cameleer.server.core.admin.AuditResult;
+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.LicenseState;
+import com.cameleer.server.core.license.LicenseValidator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LicenseServiceTest {
+
+ LicenseRepository repo;
+ LicenseGate gate;
+ AuditService audit;
+ ApplicationEventPublisher events;
+ LicenseValidator validator;
+ LicenseService svc;
+
+ @BeforeEach
+ void setUp() {
+ repo = mock(LicenseRepository.class);
+ gate = new LicenseGate();
+ audit = mock(AuditService.class);
+ events = mock(ApplicationEventPublisher.class);
+ validator = mock(LicenseValidator.class);
+ svc = new LicenseService("default", repo, gate, validator, audit, events);
+ }
+
+ @Test
+ void install_validToken_persistsAndPublishes() {
+ LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "default", null,
+ Map.of("max_apps", 5), Instant.now(),
+ Instant.now().plusSeconds(86400), 0);
+ when(validator.validate("tok")).thenReturn(info);
+ when(repo.findByTenantId("default")).thenReturn(Optional.empty());
+
+ svc.install("tok", "alice", "api");
+
+ assertThat(gate.getCurrent()).isEqualTo(info);
+ assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
+ verify(repo).upsert(any(LicenseRecord.class));
+ verify(events).publishEvent(any(LicenseChangedEvent.class));
+ verify(audit).log(eq("alice"), eq("install_license"), eq(AuditCategory.LICENSE),
+ any(), any(), eq(AuditResult.SUCCESS), isNull());
+ }
+
+ @Test
+ void install_invalidToken_marksGateInvalidAndAudits() {
+ when(validator.validate("bad")).thenThrow(new SecurityException("signature failed"));
+
+ try {
+ svc.install("bad", "alice", "api");
+ } catch (Exception ignored) {}
+
+ assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
+ assertThat(gate.getInvalidReason()).contains("signature failed");
+ verify(repo, never()).upsert(any());
+ verify(audit).log(eq("alice"), eq("reject_license"), eq(AuditCategory.LICENSE),
+ any(), any(), eq(AuditResult.FAILURE), isNull());
+ }
+
+ @Test
+ void install_replacingExistingLicense_auditsReplace() {
+ LicenseInfo old = new LicenseInfo(UUID.randomUUID(), "default", null,
+ Map.of(), Instant.now(),
+ Instant.now().plusSeconds(86400), 0);
+ gate.load(old);
+ when(repo.findByTenantId("default")).thenReturn(Optional.of(
+ new LicenseRecord("default", "old", old.licenseId(),
+ Instant.now(), "system",
+ Instant.now().plusSeconds(86400), Instant.now())));
+ LicenseInfo fresh = new LicenseInfo(UUID.randomUUID(), "default", null,
+ Map.of(), Instant.now(),
+ Instant.now().plusSeconds(86400), 0);
+ when(validator.validate("new")).thenReturn(fresh);
+
+ svc.install("new", "alice", "api");
+
+ verify(audit).log(eq("alice"), eq("replace_license"), eq(AuditCategory.LICENSE),
+ any(), any(), eq(AuditResult.SUCCESS), isNull());
+ }
+}