# License Enforcement — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Enforce arbitrary per-customer license limits (entity counts, compute, retention) on a default-tier-by-default server, with a vendor-only standalone minter and PostgreSQL-persisted licenses. **Architecture:** Three modules — `cameleer-server-core` carries the validator/state-machine/defaults, `cameleer-server-app` carries the enforcement, persistence, REST, and ClickHouse-TTL applier, and a new `cameleer-license-minter` top-level Maven module (vendor-only, not in the runtime tree) carries the signing primitive and CLI. License envelope is a base64(payload).base64(ed25519) token; runtime install via `POST /admin/license` writes through to PG; an event bus recomputes ClickHouse TTL on every license change; a daily revalidation job refreshes `last_validated_at` and catches public-key drift. **Tech Stack:** Java 17, Spring Boot 3.4.3, JdbcTemplate over PostgreSQL (Flyway), ClickHouse (jdbc), Ed25519 (`java.security.Signature`), Jackson canonical JSON, JUnit 5 + AssertJ + Mockito, Testcontainers Postgres + ClickHouse, REST-Assured-style `MockMvc` for IT. **Spec:** `docs/superpowers/specs/2026-04-25-license-enforcement-design.md` --- ## Conventions for the executor - **Before editing any existing class**, run `gitnexus_impact({target: "ClassName", direction: "upstream"})` and report blast radius. Refuse HIGH/CRITICAL without confirming. - **Before each commit**, run `gitnexus_detect_changes()` to verify scope. - After committing, run `npx gitnexus analyze --embeddings` (PostToolUse hook may automate). - Per-test runs: `mvn -pl cameleer-server-core -Dtest=ClassName#method test` or `mvn -pl cameleer-server-app -Dtest=ClassName#method test`. - Full IT run (slow, Testcontainers): `mvn -pl cameleer-server-app verify`. - Fast unit-only: `mvn -pl cameleer-server-core test && mvn -pl cameleer-server-app test -DskipITs`. - After any controller signature change in this plan, regenerate OpenAPI types (Task 35). - After adding/removing/renaming classes/controllers, update `.claude/rules/*.md` (Task 36). - Commit per step that says "Commit". Use `feat(license):`, `fix(license):`, `refactor(license):`, `test(license):`, `docs(license):` prefixes. End every commit message body with `Co-Authored-By: Claude Opus 4.7 (1M context) `. --- ## Files touched (summary) **Created:** - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` (enum) - `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` - `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` - `cameleer-license-minter/pom.xml` - `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` - `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` - `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` - `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` - `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` **Modified:** - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` (drop `features`, add `licenseId`/`tenantId`/`gracePeriodDays`; require fields) - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` (require new fields, reject tenant mismatch via constructor arg, drop `features`) - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` (drop `isEnabled(Feature)`; add `getEffectiveLimits()`/`getState()`) - `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` (add 3 retention fields) - `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (cap retention values via enforcer hook) - `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` (boot order: env > file > DB; publish event) - `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` (persist + audit + return state) - `cameleer-server-app/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` (add `LICENSE`) - All "wire enforcement" call sites listed in Tasks 18–25 - `pom.xml` (root: register `cameleer-license-minter` module) - `.claude/rules/core-classes.md`, `.claude/rules/app-classes.md` (Task 36) - `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` (Task 35) **Deleted:** - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` - The `features` portions of `LicenseInfo`, `LicenseGate`, `LicenseValidator` - `LicenseGateTest#withLicense_onlyLicensedFeaturesEnabled` --- ## Task 1: Remove dead `Feature` scaffolding (first commit) **Why:** Spec §9 — `Feature` enum is removed before any new code lands so subsequent edits don't have to maintain a dual world. **Files:** - Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` - Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` - Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` - [ ] **Step 1.1: Run gitnexus impact for `Feature`, `LicenseGate.isEnabled`, `LicenseInfo.hasFeature`** ``` gitnexus_impact({target: "Feature", direction: "upstream"}) gitnexus_impact({target: "isEnabled", direction: "upstream"}) gitnexus_impact({target: "hasFeature", direction: "upstream"}) ``` Expected: only the LicenseGate/LicenseInfo classes themselves and tests reference these — no production callers. - [ ] **Step 1.2: Delete `Feature.java`** ```bash rm cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java ``` - [ ] **Step 1.3: Replace `LicenseInfo.java` with the new shape (still without `licenseId`/`tenantId` — Task 2 adds those)** ```java package com.cameleer.server.core.license; import java.time.Instant; import java.util.Map; public record LicenseInfo( String tier, Map limits, Instant issuedAt, Instant expiresAt ) { public boolean isExpired() { return expiresAt != null && Instant.now().isAfter(expiresAt); } public int getLimit(String key, int defaultValue) { return limits.getOrDefault(key, defaultValue); } public static LicenseInfo open() { return new LicenseInfo("open", Map.of(), Instant.now(), null); } } ``` - [ ] **Step 1.4: Replace `LicenseGate.java` (drop `isEnabled`)** ```java package com.cameleer.server.core.license; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.atomic.AtomicReference; public class LicenseGate { private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); private final AtomicReference current = new AtomicReference<>(LicenseInfo.open()); public void load(LicenseInfo license) { current.set(license); log.info("License loaded: tier={}, limits={}, expires={}", license.tier(), license.limits(), license.expiresAt()); } public String getTier() { return current.get().tier(); } public int getLimit(String key, int defaultValue) { return current.get().getLimit(key, defaultValue); } public LicenseInfo getCurrent() { return current.get(); } } ``` - [ ] **Step 1.5: Update `LicenseValidator.java` — drop the `features` parsing block** In the `validate(String token)` method, delete the entire `Set features = ...` block (lines 59–68 of the original) and remove the corresponding constructor arg from the `new LicenseInfo(...)` call so it matches Step 1.3's shape: ```java LicenseInfo info = new LicenseInfo(tier, limits, issuedAt, expiresAt); ``` Also remove the `import java.util.HashSet;` if no other usage remains. - [ ] **Step 1.6: Update `LicenseGateTest.java`** Delete `withLicense_onlyLicensedFeaturesEnabled()` and `noLicense_allFeaturesEnabled()` entirely. Replace the file with: ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class LicenseGateTest { @Test void noLicense_returnsOpenTier() { LicenseGate gate = new LicenseGate(); assertThat(gate.getTier()).isEqualTo("open"); assertThat(gate.getLimit("max_apps", 99)).isEqualTo(99); } @Test void loaded_exposesLimits() { LicenseGate gate = new LicenseGate(); LicenseInfo info = new LicenseInfo("MID", Map.of("max_agents", 10, "retention_days", 30), Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); gate.load(info); assertThat(gate.getTier()).isEqualTo("MID"); assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); assertThat(gate.getLimit("missing", 7)).isEqualTo(7); } } ``` - [ ] **Step 1.7: Update `LicenseValidatorTest.java` — remove `features` from sample payloads and `Feature` assertions** Replace the JSON payload string in `validate_validLicense_returnsLicenseInfo` with: ```java String payload = """ {"tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); ``` Delete the lines: ```java assertThat(info.hasFeature(Feature.debugger)).isTrue(); assertThat(info.hasFeature(Feature.replay)).isFalse(); ``` In `validate_expiredLicense_throwsException`, replace its payload with: ```java String payload = """ {"tier":"LOW","limits":{},"iat":%d,"exp":%d} """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); ``` In `validate_tamperedPayload_throwsException`, replace its payload with: ```java String payload = """ {"tier":"LOW","limits":{},"iat":0,"exp":9999999999} """.trim(); ``` Remove the `import com.cameleer.server.core.license.Feature;` if any. - [ ] **Step 1.8: Build core unit tests** Run: `mvn -pl cameleer-server-core test` Expected: BUILD SUCCESS, 3 tests in `LicenseValidatorTest`, 2 in `LicenseGateTest`. - [ ] **Step 1.9: Build app unit tests (only the moved tests; ITs skipped)** Run: `mvn -pl cameleer-server-app test -DskipITs` Expected: BUILD SUCCESS — no other code touched yet. - [ ] **Step 1.10: Commit** ```bash git add -u cameleer-server-core cameleer-server-app git commit -m "$(cat <<'EOF' refactor(license): remove dead Feature enum and isEnabled scaffolding Spec §9 — feature flags are out of scope for license enforcement. Drops Feature.java, LicenseGate.isEnabled, LicenseInfo.hasFeature, and the corresponding test cases. LicenseValidator now silently ignores any features array on the wire (no error). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 2: Expand `LicenseInfo` schema — required `licenseId`, `tenantId`, `gracePeriodDays` **Why:** Spec §2 — the new envelope adds three fields. `licenseId` and `tenantId` are required for audit and anti-portability respectively. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` - Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseInfoTest.java` (NEW) - [ ] **Step 2.1: Write failing test — `LicenseInfoTest.java`** Create the file with: ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class LicenseInfoTest { @Test void requiresLicenseId() { assertThatThrownBy(() -> new LicenseInfo( null, "acme", "label", Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("licenseId"); } @Test void requiresTenantId() { assertThatThrownBy(() -> new LicenseInfo( UUID.randomUUID(), null, "label", Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("tenantId"); } @Test void emptyTenantIdRejected() { assertThatThrownBy(() -> new LicenseInfo( UUID.randomUUID(), " ", "label", Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0)) .isInstanceOf(IllegalArgumentException.class); } @Test void getLimit_returnsDefaultWhenMissing() { LicenseInfo info = new LicenseInfo( UUID.randomUUID(), "acme", null, Map.of("max_apps", 5), Instant.now(), Instant.now().plusSeconds(60), 0); assertThat(info.getLimit("max_apps", 99)).isEqualTo(5); assertThat(info.getLimit("max_users", 99)).isEqualTo(99); } @Test void isExpired_honoursGracePeriod() { Instant pastByTen = Instant.now().minusSeconds(10 * 86400); LicenseInfo withinGrace = new LicenseInfo( UUID.randomUUID(), "acme", null, Map.of(), Instant.now().minusSeconds(40 * 86400), pastByTen, 30); assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace LicenseInfo pastGrace = new LicenseInfo( UUID.randomUUID(), "acme", null, Map.of(), Instant.now().minusSeconds(40 * 86400), pastByTen, 5); assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace } } ``` - [ ] **Step 2.2: Run — expect compile failure** Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` Expected: FAIL — `LicenseInfo` constructor signature does not match. - [ ] **Step 2.3: Update `LicenseInfo.java` to the new shape** ```java package com.cameleer.server.core.license; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.UUID; public record LicenseInfo( UUID licenseId, String tenantId, String label, Map limits, Instant issuedAt, Instant expiresAt, int gracePeriodDays ) { public LicenseInfo { Objects.requireNonNull(licenseId, "licenseId is required"); Objects.requireNonNull(tenantId, "tenantId is required"); Objects.requireNonNull(limits, "limits is required"); Objects.requireNonNull(issuedAt, "issuedAt is required"); Objects.requireNonNull(expiresAt, "expiresAt is required"); if (tenantId.isBlank()) { throw new IllegalArgumentException("tenantId must not be blank"); } if (gracePeriodDays < 0) { throw new IllegalArgumentException("gracePeriodDays must be >= 0"); } } public boolean isExpired() { Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400); return Instant.now().isAfter(deadline); } public boolean isAfterRawExpiry() { return Instant.now().isAfter(expiresAt); } public int getLimit(String key, int defaultValue) { return limits.getOrDefault(key, defaultValue); } } ``` Note: `LicenseInfo.open()` is removed — `LicenseGate` will switch to a sentinel `null` for ABSENT in Task 5. - [ ] **Step 2.4: Run the test** Run: `mvn -pl cameleer-server-core test -Dtest=LicenseInfoTest` Expected: PASS, 5/5. - [ ] **Step 2.5: Fix the broken callers from Task 1 (transient compile failures)** `LicenseValidator.validate(...)` still does `new LicenseInfo(tier, limits, issuedAt, expiresAt)`. Task 3 rewrites it; for now, replace just that line with a placeholder so the module compiles: ```java LicenseInfo info = new LicenseInfo( java.util.UUID.randomUUID(), "placeholder", null, limits, issuedAt, expiresAt, 0); ``` Mark this with a `// TODO Task 3` comment so the executor cannot forget. `LicenseGate` references `LicenseInfo.open()`. Add a temporary inline stub at the top of `LicenseGate`: ```java private static LicenseInfo openSentinel() { return new LicenseInfo(java.util.UUID.randomUUID(), "open", null, Map.of(), Instant.EPOCH, Instant.MAX, 0); } ``` And use `openSentinel()` instead of `LicenseInfo.open()` in the field initialiser. This is also TODO-marked for Task 5 to replace. `LicenseGateTest` (from Task 1.6) uses the old 4-arg constructor; update its `new LicenseInfo(...)` call to the 7-arg form using the same placeholder pattern. `LicenseValidatorTest` builds JSON, not Java objects — no change needed. - [ ] **Step 2.6: Build & test** Run: `mvn -pl cameleer-server-core test` Expected: PASS — the placeholders compile and existing tests assert nothing about the placeholder fields. - [ ] **Step 2.7: Commit** ```bash git add cameleer-server-core/src git commit -m "$(cat <<'EOF' feat(license): expand LicenseInfo with licenseId, tenantId, grace period Required fields per spec §2.1. tenantId is non-blank; gracePeriodDays defines the post-exp window during which limits keep applying. isExpired() now honours the grace; isAfterRawExpiry() distinguishes ACTIVE from GRACE for the state machine in Task 4. Validator and gate use placeholder values temporarily; Task 3 wires the validator to read the new fields, Task 5 rewrites the gate. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 3: Rewrite `LicenseValidator` for the new envelope (require `licenseId`/`tenantId`, optional tenant binding check) **Why:** Spec §2.1 + §6.4 — validator must reject tokens missing required fields and tokens whose `tenantId` does not match the configured server tenant. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` - Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` - [ ] **Step 3.1: Run gitnexus impact** ``` gitnexus_impact({target: "LicenseValidator", direction: "upstream"}) ``` Expected: `LicenseBeanConfig`, `LicenseAdminController`, the test class. All will be touched downstream. - [ ] **Step 3.2: Write failing tests in `LicenseValidatorTest.java`** Add three new test methods (keep the existing three, but update their JSON payloads to include the new required fields): ```java @Test void validate_missingTenantId_throws() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); String payload = """ {"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d} """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); assertThatThrownBy(() -> validator.validate(token)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("tenantId"); } @Test void validate_tenantIdMismatch_throws() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta"); Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); String payload = """ {"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); assertThatThrownBy(() -> validator.validate(token)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("tenantId"); } @Test void validate_missingLicenseId_throws() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); String payload = """ {"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} """.formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); assertThatThrownBy(() -> validator.validate(token)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("licenseId"); } ``` Update `validate_validLicense_returnsLicenseInfo` payload to include both required fields: ```java String payload = """ {"licenseId":"%s","tenantId":"acme","tier":"HIGH","limits":{"max_agents":50},"iat":%d,"exp":%d,"gracePeriodDays":7} """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); ``` And construct the validator with the matching tenant: ```java LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); ``` Add asserts: ```java assertThat(info.tenantId()).isEqualTo("acme"); assertThat(info.gracePeriodDays()).isEqualTo(7); ``` Apply the same `tenantId`/`licenseId` additions to `validate_expiredLicense_throwsException` and `validate_tamperedPayload_throwsException` payloads. Add `import java.util.UUID;` at the top. - [ ] **Step 3.3: Run — expect failures** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` Expected: FAIL — `LicenseValidator` ctor takes only one arg. - [ ] **Step 3.4: Rewrite `LicenseValidator.java`** ```java package com.cameleer.server.core.license; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.*; import java.security.spec.X509EncodedKeySpec; import java.time.Instant; import java.util.*; public class LicenseValidator { private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class); private static final ObjectMapper objectMapper = new ObjectMapper(); private final PublicKey publicKey; private final String expectedTenantId; public LicenseValidator(String publicKeyBase64, String expectedTenantId) { Objects.requireNonNull(expectedTenantId, "expectedTenantId is required"); if (expectedTenantId.isBlank()) { throw new IllegalArgumentException("expectedTenantId must not be blank"); } try { byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); KeyFactory kf = KeyFactory.getInstance("Ed25519"); this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes)); } catch (Exception e) { throw new IllegalStateException("Failed to load license public key", e); } this.expectedTenantId = expectedTenantId; } public LicenseInfo validate(String token) { String[] parts = token.split("\\.", 2); if (parts.length != 2) { throw new IllegalArgumentException("Invalid license token format: expected payload.signature"); } byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); try { Signature verifier = Signature.getInstance("Ed25519"); verifier.initVerify(publicKey); verifier.update(payloadBytes); if (!verifier.verify(signatureBytes)) { throw new SecurityException("License signature verification failed"); } } catch (SecurityException e) { throw e; } catch (Exception e) { throw new SecurityException("License signature verification failed", e); } try { JsonNode root = objectMapper.readTree(payloadBytes); String licenseIdStr = textOrThrow(root, "licenseId"); UUID licenseId; try { licenseId = UUID.fromString(licenseIdStr); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr); } String tenantId = textOrThrow(root, "tenantId"); if (!tenantId.equals(expectedTenantId)) { throw new IllegalArgumentException( "License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'"); } String tier = root.has("tier") ? root.get("tier").asText() : "unspecified"; String label = root.has("label") ? root.get("label").asText() : null; Map limits = new HashMap<>(); if (root.has("limits")) { root.get("limits").fields().forEachRemaining(entry -> limits.put(entry.getKey(), entry.getValue().asInt())); } Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); if (!root.has("exp")) { throw new IllegalArgumentException("exp is required"); } Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong()); int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0; LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays); if (info.isExpired()) { throw new IllegalArgumentException("License expired at " + expiresAt + " (grace period " + gracePeriodDays + " days)"); } return info; } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("Failed to parse license payload", e); } } private static String textOrThrow(JsonNode root, String field) { if (!root.has(field) || root.get(field).asText().isBlank()) { throw new IllegalArgumentException(field + " is required"); } return root.get(field).asText(); } } ``` Note `tier` is now optional with a default — spec calls it a label-only field; we keep parsing it for backward compatibility with old hand-written tokens. - [ ] **Step 3.5: Run validator tests** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseValidatorTest` Expected: PASS, 6/6. - [ ] **Step 3.6: Update `LicenseBeanConfig` — pass tenant id to validator (compile fix)** In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`, inject the tenant id and update the two `new LicenseValidator(...)` calls: ```java @Value("${cameleer.server.tenant.id:default}") private String tenantId; ``` And change both call sites: ```java LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId); ``` Apply the same change to `LicenseAdminController.java` line 45 (use `@Value("${cameleer.server.tenant.id:default}")` injected via the constructor). - [ ] **Step 3.7: Build app** Run: `mvn -pl cameleer-server-app compile` Expected: BUILD SUCCESS. - [ ] **Step 3.8: Commit** ```bash git add cameleer-server-core cameleer-server-app git commit -m "$(cat <<'EOF' feat(license): require licenseId + tenantId in validator Spec §2.1 — both fields are required and the validator rejects a token whose tenantId does not match the server's configured tenant (CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip tenantId because the field is in the signed payload. LicenseBeanConfig and LicenseAdminController updated to pass the expected tenant to the validator constructor. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 4: Add `LicenseLimits`, `DefaultTierLimits`, `LicenseState`, `LicenseStateMachine` **Why:** Spec §3 — pure FSM + the default-tier constants live in `core` so app and minter can both use them. **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java` - Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java` - Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java` - [ ] **Step 4.1: Write failing test — `DefaultTierLimitsTest.java`** ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class DefaultTierLimitsTest { @Test void allDocumentedKeysHaveDefaults() { for (String key : new String[]{ "max_environments", "max_apps", "max_agents", "max_users", "max_outbound_connections", "max_alert_rules", "max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas", "max_execution_retention_days", "max_log_retention_days", "max_metric_retention_days", "max_jar_retention_count" }) { assertThat(DefaultTierLimits.DEFAULTS).containsKey(key); } } @Test void specificValues() { assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1); assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3); assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5); assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000); assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1); } } ``` - [ ] **Step 4.2: Run — expect compile failure** Run: `mvn -pl cameleer-server-core test -Dtest=DefaultTierLimitsTest` Expected: FAIL — class missing. - [ ] **Step 4.3: Create `DefaultTierLimits.java`** ```java package com.cameleer.server.core.license; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; public final class DefaultTierLimits { public static final Map DEFAULTS; static { Map m = new LinkedHashMap<>(); m.put("max_environments", 1); m.put("max_apps", 3); m.put("max_agents", 5); m.put("max_users", 3); m.put("max_outbound_connections", 1); m.put("max_alert_rules", 2); m.put("max_total_cpu_millis", 2000); m.put("max_total_memory_mb", 2048); m.put("max_total_replicas", 5); m.put("max_execution_retention_days", 1); m.put("max_log_retention_days", 1); m.put("max_metric_retention_days", 1); m.put("max_jar_retention_count", 3); DEFAULTS = Collections.unmodifiableMap(m); } private DefaultTierLimits() {} } ``` - [ ] **Step 4.4: Create `LicenseLimits.java`** ```java package com.cameleer.server.core.license; import java.util.Map; import java.util.Objects; public record LicenseLimits(Map values) { public LicenseLimits { Objects.requireNonNull(values, "values"); } public static LicenseLimits defaultsOnly() { return new LicenseLimits(DefaultTierLimits.DEFAULTS); } public static LicenseLimits mergeOverDefaults(Map overrides) { java.util.Map merged = new java.util.LinkedHashMap<>(DefaultTierLimits.DEFAULTS); if (overrides != null) merged.putAll(overrides); return new LicenseLimits(java.util.Collections.unmodifiableMap(merged)); } public int get(String key) { Integer v = values.get(key); if (v == null) { throw new IllegalArgumentException("Unknown license limit key: " + key); } return v; } public boolean isDefaultSourced(String key, LicenseInfo license) { if (license == null) return true; return !license.limits().containsKey(key); } } ``` - [ ] **Step 4.5: Create `LicenseState.java`** ```java package com.cameleer.server.core.license; public enum LicenseState { ABSENT, ACTIVE, GRACE, EXPIRED, INVALID } ``` - [ ] **Step 4.6: Write failing test — `LicenseStateMachineTest.java`** ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseStateMachineTest { @Test void noLicense_isAbsent() { assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT); } @Test void invalidReason_isInvalid() { assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID); } @Test void activeBeforeExp() { LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE); } @Test void graceWithinGracePeriod() { LicenseInfo info = info(Instant.now().minusSeconds(86400), 7); assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE); } @Test void expiredAfterGrace() { LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7); assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); } @Test void expiredImmediatelyWithZeroGrace() { LicenseInfo info = info(Instant.now().minusSeconds(60), 0); assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); } @Test void invalidWinsOverPresentLicense() { LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID); } private LicenseInfo info(Instant exp, int graceDays) { return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), Instant.now().minusSeconds(3600), exp, graceDays); } } ``` - [ ] **Step 4.7: Run — expect compile failure** Run: `mvn -pl cameleer-server-core test -Dtest=LicenseStateMachineTest` Expected: FAIL — class missing. - [ ] **Step 4.8: Create `LicenseStateMachine.java`** ```java package com.cameleer.server.core.license; public final class LicenseStateMachine { private LicenseStateMachine() {} /** * @param license parsed license, or null if no license is loaded * @param invalidReason non-null if the last validation attempt failed */ public static LicenseState classify(LicenseInfo license, String invalidReason) { if (invalidReason != null) { return LicenseState.INVALID; } if (license == null) { return LicenseState.ABSENT; } if (!license.isAfterRawExpiry()) { return LicenseState.ACTIVE; } if (!license.isExpired()) { return LicenseState.GRACE; } return LicenseState.EXPIRED; } } ``` - [ ] **Step 4.9: Run all core tests** Run: `mvn -pl cameleer-server-core test` Expected: PASS — all four new tests + existing. - [ ] **Step 4.10: Commit** ```bash git add cameleer-server-core/src git commit -m "$(cat <<'EOF' feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine Pure-domain FSM (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID) and the default-tier constants per spec §3. invalidReason wins over any loaded license so signature failures surface as INVALID rather than masking as ABSENT. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 5: Rewrite `LicenseGate` with state, effective limits, and invalidReason **Why:** Spec §3.1 — gate must expose state + effective limits (merged over defaults) for the enforcer and usage reader. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` - Modify: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` - [ ] **Step 5.1: Run gitnexus impact** ``` gitnexus_impact({target: "LicenseGate", direction: "upstream"}) ``` - [ ] **Step 5.2: Write failing test — replace `LicenseGateTest.java` body** ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseGateTest { @Test void absent_byDefault() { LicenseGate gate = new LicenseGate(); assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); assertThat(gate.getEffectiveLimits().get("max_apps")) .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); assertThat(gate.getCurrent()).isNull(); assertThat(gate.getInvalidReason()).isNull(); } @Test void load_setsActiveAndMergesLimits() { LicenseGate gate = new LicenseGate(); LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label", Map.of("max_apps", 50), Instant.now(), Instant.now().plusSeconds(86400), 0); gate.load(info); assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50); // unspecified key still inherits default assertThat(gate.getEffectiveLimits().get("max_users")) .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users")); } @Test void markInvalid_overridesActive() { LicenseGate gate = new LicenseGate(); LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of("max_apps", 50), Instant.now(), Instant.now().plusSeconds(86400), 0); gate.load(info); gate.markInvalid("signature failed"); assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); // limits revert to defaults when INVALID assertThat(gate.getEffectiveLimits().get("max_apps")) .isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps")); assertThat(gate.getInvalidReason()).isEqualTo("signature failed"); } @Test void clear_returnsToAbsent() { LicenseGate gate = new LicenseGate(); LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), Instant.now(), Instant.now().plusSeconds(86400), 0); gate.load(info); gate.clear(); assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT); assertThat(gate.getCurrent()).isNull(); } } ``` - [ ] **Step 5.3: Run — expect failures** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseGateTest` Expected: FAIL — `getState`, `getEffectiveLimits`, `markInvalid`, `clear` missing. - [ ] **Step 5.4: Replace `LicenseGate.java`** ```java package com.cameleer.server.core.license; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.atomic.AtomicReference; public class LicenseGate { private static final Logger log = LoggerFactory.getLogger(LicenseGate.class); private static final class Snapshot { final LicenseInfo license; // null when ABSENT or INVALID final String invalidReason; // null unless INVALID Snapshot(LicenseInfo l, String r) { this.license = l; this.invalidReason = r; } } private final AtomicReference snap = new AtomicReference<>(new Snapshot(null, null)); public void load(LicenseInfo license) { snap.set(new Snapshot(license, null)); log.info("License loaded: licenseId={}, tenantId={}, exp={}, gracePeriodDays={}", license.licenseId(), license.tenantId(), license.expiresAt(), license.gracePeriodDays()); } public void markInvalid(String reason) { snap.set(new Snapshot(null, reason)); log.error("License marked INVALID: {}", reason); } public void clear() { snap.set(new Snapshot(null, null)); log.info("License cleared"); } public LicenseInfo getCurrent() { return snap.get().license; } public String getInvalidReason() { return snap.get().invalidReason; } public LicenseState getState() { Snapshot s = snap.get(); return LicenseStateMachine.classify(s.license, s.invalidReason); } /** Effective limits = defaults UNION license.limits, except in EXPIRED/ABSENT/INVALID where defaults win. */ public LicenseLimits getEffectiveLimits() { Snapshot s = snap.get(); LicenseState state = LicenseStateMachine.classify(s.license, s.invalidReason); if (state == LicenseState.ACTIVE || state == LicenseState.GRACE) { return LicenseLimits.mergeOverDefaults(s.license.limits()); } return LicenseLimits.defaultsOnly(); } public int getLimit(String key, int defaultValue) { try { return getEffectiveLimits().get(key); } catch (IllegalArgumentException e) { return defaultValue; } } } ``` - [ ] **Step 5.5: Remove temporary `openSentinel()` from prior tasks (compile ripple)** Inside `LicenseGate.java` from Task 2.5, the temporary `openSentinel()` and field initialiser using it are now superseded by the new file in Step 5.4 — already replaced. Sanity-check by grepping: Run: `grep -r openSentinel cameleer-server-core/src cameleer-server-app/src || echo OK` Expected: prints `OK`. - [ ] **Step 5.6: Run all core tests** Run: `mvn -pl cameleer-server-core test` Expected: PASS. - [ ] **Step 5.7: Run app unit tests** Run: `mvn -pl cameleer-server-app test -DskipITs` Expected: PASS. - [ ] **Step 5.8: Commit** ```bash git add cameleer-server-core cameleer-server-app git commit -m "$(cat <<'EOF' feat(license): rewrite LicenseGate around state + effective limits LicenseGate now exposes getState() (delegates to LicenseStateMachine), getEffectiveLimits() (merged over DefaultTierLimits in ACTIVE/GRACE, defaults-only in ABSENT/EXPIRED/INVALID), markInvalid(reason), and clear(). Internal snapshot is an immutable record swapped atomically so concurrent reads see a consistent license+reason pair. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 6: Create `cameleer-license-minter` Maven module **Why:** Spec §1.1 — vendor-only signing module; not on `cameleer-server-app`'s classpath. **Files:** - Create: `cameleer-license-minter/pom.xml` - Modify: `pom.xml` (root) — add module - [ ] **Step 6.1: Create the module directory and pom** ```bash mkdir -p cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli mkdir -p cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli ``` - [ ] **Step 6.2: Write `cameleer-license-minter/pom.xml`** ```xml 4.0.0 com.cameleer cameleer-server-parent 1.0-SNAPSHOT cameleer-license-minter Cameleer License Minter Vendor-only Ed25519 license signing library + CLI com.cameleer cameleer-server-core com.fasterxml.jackson.core jackson-databind org.slf4j slf4j-api org.junit.jupiter junit-jupiter test org.assertj assertj-core test org.springframework.boot spring-boot-maven-plugin repackage-cli repackage cli com.cameleer.license.minter.cli.LicenseMinterCli ``` - [ ] **Step 6.3: Register the module in root `pom.xml`** Edit `pom.xml`, locate the `` block, add the new entry: ```xml cameleer-server-core cameleer-server-app cameleer-license-minter ``` - [ ] **Step 6.4: Verify build** Run: `mvn -pl cameleer-license-minter -am compile` Expected: BUILD SUCCESS — no Java sources yet, but module is recognised. - [ ] **Step 6.5: Commit** ```bash git add pom.xml cameleer-license-minter/pom.xml git commit -m "$(cat <<'EOF' feat(license-minter): add cameleer-license-minter Maven module Top-level module sibling to cameleer-server-core/-app. Depends on cameleer-server-core for the LicenseInfo schema. Spring Boot repackage produces a runnable -cli classifier for the vendor. Not added as a dependency from cameleer-server-app — runtime tree must not carry signing primitives. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 7: Implement `LicenseMinter` library + canonical-JSON serializer **Why:** Spec §7.1 — pure signer used by both the CLI and cameleer-saas. **Files:** - Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java` - Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java` - [ ] **Step 7.1: Write failing test** ```java package com.cameleer.license.minter; import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.server.core.license.LicenseValidator; import org.junit.jupiter.api.Test; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.time.Instant; import java.util.Base64; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseMinterTest { @Test void roundTrip_validatorAcceptsMintedToken() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); String publicB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseInfo info = new LicenseInfo( UUID.randomUUID(), "acme", "ACME prod", Map.of("max_apps", 50, "max_agents", 100), Instant.now(), Instant.now().plusSeconds(86400), 7); String token = LicenseMinter.mint(info, kp.getPrivate()); LicenseInfo parsed = new LicenseValidator(publicB64, "acme").validate(token); assertThat(parsed.licenseId()).isEqualTo(info.licenseId()); assertThat(parsed.tenantId()).isEqualTo("acme"); assertThat(parsed.limits().get("max_apps")).isEqualTo(50); assertThat(parsed.gracePeriodDays()).isEqualTo(7); } @Test void canonicalJson_isStableAcrossRuns() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); UUID id = UUID.randomUUID(); Instant now = Instant.parse("2026-04-25T10:00:00Z"); Instant exp = Instant.parse("2027-04-25T10:00:00Z"); LicenseInfo info = new LicenseInfo(id, "acme", "label", java.util.LinkedHashMap.newLinkedHashMap(2) // intentional non-sorted insertion order .ordered() ? null : null, // placeholder; rebuild below now, exp, 0); // Build the map in non-alpha order and ensure canonical output is sorted java.util.LinkedHashMap limits = new java.util.LinkedHashMap<>(); limits.put("max_apps", 5); limits.put("max_agents", 10); LicenseInfo info2 = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); String t1 = LicenseMinter.mint(info2, kp.getPrivate()); String t2 = LicenseMinter.mint(info2, kp.getPrivate()); assertThat(t1).isEqualTo(t2); } } ``` (Strip the bogus `LinkedHashMap.newLinkedHashMap` call — it's there only to fail. The real test below is `info2`.) Replace the entire test method body of `canonicalJson_isStableAcrossRuns` with just the `info2` construction and assertions: ```java @Test void canonicalJson_isStableAcrossRuns() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); UUID id = UUID.randomUUID(); Instant now = Instant.parse("2026-04-25T10:00:00Z"); Instant exp = Instant.parse("2027-04-25T10:00:00Z"); java.util.LinkedHashMap limits = new java.util.LinkedHashMap<>(); limits.put("max_apps", 5); limits.put("max_agents", 10); LicenseInfo info = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); String t1 = LicenseMinter.mint(info, kp.getPrivate()); String t2 = LicenseMinter.mint(info, kp.getPrivate()); assertThat(t1).isEqualTo(t2); } ``` - [ ] **Step 7.2: Run — expect compile failure** Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterTest` Expected: FAIL — class missing. - [ ] **Step 7.3: Implement `LicenseMinter.java`** ```java package com.cameleer.license.minter; import com.cameleer.server.core.license.LicenseInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ObjectNode; import java.security.PrivateKey; import java.security.Signature; import java.util.Base64; import java.util.TreeMap; public final class LicenseMinter { private static final ObjectMapper MAPPER = new ObjectMapper() .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); private LicenseMinter() {} public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey) { byte[] payload = canonicalPayload(info); try { Signature signer = Signature.getInstance("Ed25519"); signer.initSign(ed25519PrivateKey); signer.update(payload); byte[] sig = signer.sign(); return Base64.getEncoder().encodeToString(payload) + "." + Base64.getEncoder().encodeToString(sig); } catch (Exception e) { throw new IllegalStateException("Failed to sign license", e); } } static byte[] canonicalPayload(LicenseInfo info) { ObjectNode root = MAPPER.createObjectNode(); root.put("exp", info.expiresAt().getEpochSecond()); root.put("gracePeriodDays", info.gracePeriodDays()); root.put("iat", info.issuedAt().getEpochSecond()); if (info.label() != null) { root.put("label", info.label()); } root.put("licenseId", info.licenseId().toString()); ObjectNode limits = MAPPER.createObjectNode(); new TreeMap<>(info.limits()).forEach(limits::put); root.set("limits", limits); root.put("tenantId", info.tenantId()); try { return MAPPER.writeValueAsBytes(root); } catch (Exception e) { throw new IllegalStateException("Failed to serialize license payload", e); } } } ``` - [ ] **Step 7.4: Run tests** Run: `mvn -pl cameleer-license-minter test` Expected: PASS — both tests. - [ ] **Step 7.5: Commit** ```bash git add cameleer-license-minter git commit -m "$(cat <<'EOF' feat(license-minter): implement LicenseMinter library Pure signing primitive: serialises LicenseInfo to canonical JSON (sorted top-level keys via ORDER_MAP_ENTRIES_BY_KEYS plus a TreeMap for the limits sub-object) then signs with Ed25519. Round-trips through LicenseValidator and is byte-stable across runs for identical inputs. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 8: Implement `LicenseMinterCli` (without `--verify` yet) **Why:** Spec §7.2 — vendor CLI. Split from `--verify` (Task 9) to keep diffs reviewable. **Files:** - Create: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` - Test: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` - [ ] **Step 8.1: Write failing test** ```java package com.cameleer.license.minter.cli; import com.cameleer.server.core.license.LicenseValidator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.util.Base64; import static org.assertj.core.api.Assertions.assertThat; class LicenseMinterCliTest { @TempDir Path tmp; @Test void mints_validToken_validatorAccepts() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); Path priv = tmp.resolve("priv.b64"); Path pub = tmp.resolve("pub.b64"); Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); Path out = tmp.resolve("license.tok"); int code = LicenseMinterCli.run(new String[]{ "--private-key=" + priv, "--tenant=acme", "--label=ACME", "--expires=2099-12-31", "--grace-days=30", "--max-apps=50", "--output=" + out }); assertThat(code).isEqualTo(0); String token = Files.readString(out).trim(); var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token); assertThat(info.tenantId()).isEqualTo("acme"); assertThat(info.limits().get("max_apps")).isEqualTo(50); assertThat(info.gracePeriodDays()).isEqualTo(30); } @Test void unknownFlag_failsFast() { int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"}); assertThat(code).isNotZero(); } } ``` - [ ] **Step 8.2: Run — expect failure** Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` Expected: FAIL — class missing. - [ ] **Step 8.3: Implement `LicenseMinterCli.java`** ```java package com.cameleer.license.minter.cli; import com.cameleer.license.minter.LicenseMinter; import com.cameleer.server.core.license.LicenseInfo; import java.io.PrintStream; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.util.*; public final class LicenseMinterCli { private static final Set KNOWN_FLAGS = Set.of( "--private-key", "--public-key", "--tenant", "--label", "--expires", "--grace-days", "--output", "--verify" ); public static void main(String[] args) { System.exit(run(args)); } public static int run(String[] args) { return run(args, System.out, System.err); } public static int run(String[] args, PrintStream out, PrintStream err) { Map flags = new LinkedHashMap<>(); Set bool = new HashSet<>(); Map limits = new TreeMap<>(); for (String arg : args) { if (!arg.startsWith("--")) { err.println("unexpected positional argument: " + arg); return 2; } int eq = arg.indexOf('='); String key = eq < 0 ? arg : arg.substring(0, eq); String value = eq < 0 ? null : arg.substring(eq + 1); if (key.startsWith("--max-")) { String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_'); if (value == null) { err.println("missing value for " + key); return 2; } limits.put(limitKey, Integer.parseInt(value)); continue; } if (!KNOWN_FLAGS.contains(key)) { err.println("unknown flag: " + key); return 2; } if (value == null) { bool.add(key); } else { flags.put(key, value); } } String privPath = flags.get("--private-key"); String tenant = flags.get("--tenant"); String expiresIso = flags.get("--expires"); if (privPath == null || tenant == null || expiresIso == null) { err.println("required: --private-key --tenant --expires"); return 2; } try { PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath)); int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0")); Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant(); LicenseInfo info = new LicenseInfo( UUID.randomUUID(), tenant, flags.get("--label"), Collections.unmodifiableMap(limits), Instant.now(), exp, graceDays ); String token = LicenseMinter.mint(info, privateKey); String outPath = flags.get("--output"); if (outPath != null) { Files.writeString(Path.of(outPath), token); out.println("wrote " + outPath); } else { out.println(token); } return 0; } catch (Exception e) { err.println("ERROR: " + e.getMessage()); return 1; } } private static PrivateKey readEd25519PrivateKey(Path path) throws Exception { String s = Files.readString(path).trim(); // Accept base64 of PKCS#8 (openssl pkey -outform DER + base64), or PEM if (s.startsWith("-----BEGIN")) { s = s.replaceAll("-----BEGIN [A-Z ]+-----", "") .replaceAll("-----END [A-Z ]+-----", "") .replaceAll("\\s", ""); } byte[] der = Base64.getDecoder().decode(s); return KeyFactory.getInstance("Ed25519") .generatePrivate(new PKCS8EncodedKeySpec(der)); } } ``` - [ ] **Step 8.4: Run tests** Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` Expected: PASS — both tests. - [ ] **Step 8.5: Commit** ```bash git add cameleer-license-minter git commit -m "$(cat <<'EOF' feat(license-minter): add LicenseMinterCli (without --verify) Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar flags to max_foo_bar limit keys, parses --expires as a UTC date, defaults --grace-days to 0. Unknown flags fail fast with exit 2. --verify path is added in the next task. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 9: Add `--verify` round-trip to `LicenseMinterCli` **Why:** Spec §7.2 — verify the freshly-minted token before shipping it to the customer. Delete the output file on failure. **Files:** - Modify: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java` - Modify: `cameleer-license-minter/src/test/java/com/cameleer/license/minter/cli/LicenseMinterCliTest.java` - [ ] **Step 9.1: Add failing tests** Append to `LicenseMinterCliTest.java`: ```java @Test void verify_happyPath_succeeds() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); Path priv = tmp.resolve("priv.b64"); Path pub = tmp.resolve("pub.b64"); Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded())); Path out = tmp.resolve("license.tok"); int code = LicenseMinterCli.run(new String[]{ "--private-key=" + priv, "--public-key=" + pub, "--tenant=acme", "--expires=2099-12-31", "--output=" + out, "--verify" }); assertThat(code).isEqualTo(0); assertThat(out).exists(); } @Test void verify_wrongPublicKey_deletesOutputAndExitsNonZero() throws Exception { KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); Path priv = tmp.resolve("priv.b64"); Path pub = tmp.resolve("pub.b64"); Files.writeString(priv, Base64.getEncoder().encodeToString(signing.getPrivate().getEncoded())); Files.writeString(pub, Base64.getEncoder().encodeToString(other.getPublic().getEncoded())); Path out = tmp.resolve("license.tok"); int code = LicenseMinterCli.run(new String[]{ "--private-key=" + priv, "--public-key=" + pub, "--tenant=acme", "--expires=2099-12-31", "--output=" + out, "--verify" }); assertThat(code).isNotZero(); assertThat(out).doesNotExist(); } @Test void verify_withoutPublicKey_fails() throws Exception { KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); Path priv = tmp.resolve("priv.b64"); Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())); int code = LicenseMinterCli.run(new String[]{ "--private-key=" + priv, "--tenant=acme", "--expires=2099-12-31", "--verify" }); assertThat(code).isNotZero(); } ``` - [ ] **Step 9.2: Run — expect failures** Run: `mvn -pl cameleer-license-minter test -Dtest=LicenseMinterCliTest` Expected: FAIL — `--verify` is silently accepted but does nothing. - [ ] **Step 9.3: Implement `--verify`** In `LicenseMinterCli.run(...)`, after computing `token` and writing the output file (if any), insert before `return 0;`: ```java if (bool.contains("--verify")) { String pubPath = flags.get("--public-key"); if (pubPath == null) { err.println("--verify requires --public-key"); if (outPath != null) Files.deleteIfExists(Path.of(outPath)); return 2; } try { String pubB64 = Files.readString(Path.of(pubPath)).trim(); new com.cameleer.server.core.license.LicenseValidator(pubB64, tenant).validate(token); out.println("verified ok"); } catch (Exception ve) { err.println("VERIFY FAILED: " + ve.getMessage()); if (outPath != null) Files.deleteIfExists(Path.of(outPath)); return 3; } } ``` - [ ] **Step 9.4: Run tests** Run: `mvn -pl cameleer-license-minter test` Expected: PASS — all five. - [ ] **Step 9.5: Commit** ```bash git add cameleer-license-minter git commit -m "$(cat <<'EOF' feat(license-minter): --verify round-trips before shipping Adds --verify (requires --public-key) to LicenseMinterCli. After writing the output file the CLI parses the freshly-minted token through LicenseValidator against the supplied public key. On verify failure the output file is deleted (so the bad token is not accidentally shipped) and the CLI exits 3. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 10: Flyway V5 — `license` table + `environments` retention columns **Why:** Spec §6.1 + §4.2. **Files:** - Create: `cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql` - [ ] **Step 10.1: Write migration** ```sql -- Per-tenant license row (one server = one tenant) CREATE TABLE license ( tenant_id TEXT PRIMARY KEY, token TEXT NOT NULL, license_id UUID NOT NULL, installed_at TIMESTAMPTZ NOT NULL, installed_by TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, last_validated_at TIMESTAMPTZ NOT NULL ); -- Per-env retention; defaults to default-tier values (1 day) so a fresh -- server lands inside the cap without operator intervention. ALTER TABLE environments ADD COLUMN execution_retention_days INTEGER NOT NULL DEFAULT 1, ADD COLUMN log_retention_days INTEGER NOT NULL DEFAULT 1, ADD COLUMN metric_retention_days INTEGER NOT NULL DEFAULT 1; ``` - [ ] **Step 10.2: Verify migration applies cleanly via SchemaBootstrapIT (will be extended in Task 35; for now smoke via app boot)** Run: `mvn -pl cameleer-server-app test -Dtest=SchemaBootstrapIT` Expected: PASS — existing assertions still hold; the new columns/table appear silently. - [ ] **Step 10.3: Commit** ```bash git add cameleer-server-app/src/main/resources/db/migration/V5__license_table_and_environment_retention.sql git commit -m "$(cat <<'EOF' feat(license): Flyway V5 — license table + environments retention columns Per-tenant license row stores the signed token, licenseId for audit, installed/expires/last_validated timestamps. environments gains three INTEGER NOT NULL DEFAULT 1 retention columns (execution, log, metric) so existing rows land inside the default-tier cap. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 11: `LicenseRecord` + `LicenseRepository` + `PostgresLicenseRepository` **Why:** Spec §6.1, §6.2 — typed access to the `license` row. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java` - [ ] **Step 11.1: Create the record + interface** `LicenseRecord.java`: ```java package com.cameleer.server.app.license; import java.time.Instant; import java.util.UUID; public record LicenseRecord( String tenantId, String token, UUID licenseId, Instant installedAt, String installedBy, Instant expiresAt, Instant lastValidatedAt ) {} ``` `LicenseRepository.java`: ```java package com.cameleer.server.app.license; import java.time.Instant; import java.util.Optional; import java.util.UUID; public interface LicenseRepository { Optional findByTenantId(String tenantId); /** Insert or replace the row for tenantId. */ void upsert(LicenseRecord record); /** Update last_validated_at to `now` and return rows affected (0 = no row). */ int touchValidated(String tenantId, Instant now); /** Delete the row (used when the operator clears a license; not a public API in v1). */ int delete(String tenantId); } ``` - [ ] **Step 11.2: Write integration test (Testcontainers Postgres)** ```java package com.cameleer.server.app.license; import com.cameleer.server.app.AbstractPostgresIT; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class PostgresLicenseRepositoryIT extends AbstractPostgresIT { @Autowired LicenseRepository repo; @Test void roundTrip() { UUID id = UUID.randomUUID(); Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); LicenseRecord rec = new LicenseRecord( "default", "tok.sig", id, now, "system", now.plus(365, ChronoUnit.DAYS), now); repo.upsert(rec); var loaded = repo.findByTenantId("default").orElseThrow(); assertThat(loaded.licenseId()).isEqualTo(id); assertThat(loaded.installedBy()).isEqualTo("system"); } @Test void touchValidated_updatesTimestamp() throws Exception { UUID id = UUID.randomUUID(); Instant t0 = Instant.now().truncatedTo(ChronoUnit.MILLIS); repo.upsert(new LicenseRecord("default", "tok.sig", id, t0, "system", t0.plus(7, ChronoUnit.DAYS), t0)); Thread.sleep(10); Instant t1 = Instant.now().truncatedTo(ChronoUnit.MILLIS); int affected = repo.touchValidated("default", t1); assertThat(affected).isEqualTo(1); assertThat(repo.findByTenantId("default").orElseThrow().lastValidatedAt()) .isAfterOrEqualTo(t1); } } ``` - [ ] **Step 11.3: Run — expect compile failure (impl missing)** Run: `mvn -pl cameleer-server-app test -Dtest=PostgresLicenseRepositoryIT` Expected: FAIL — `PostgresLicenseRepository` class missing. - [ ] **Step 11.4: Implement `PostgresLicenseRepository.java`** ```java package com.cameleer.server.app.license; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import java.sql.Timestamp; import java.time.Instant; import java.util.Optional; import java.util.UUID; public class PostgresLicenseRepository implements LicenseRepository { private final JdbcTemplate jdbc; public PostgresLicenseRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } private static final RowMapper MAPPER = (rs, n) -> new LicenseRecord( rs.getString("tenant_id"), rs.getString("token"), (UUID) rs.getObject("license_id"), rs.getTimestamp("installed_at").toInstant(), rs.getString("installed_by"), rs.getTimestamp("expires_at").toInstant(), rs.getTimestamp("last_validated_at").toInstant() ); @Override public Optional findByTenantId(String tenantId) { return jdbc.query( "SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " + "FROM license WHERE tenant_id = ?", MAPPER, tenantId).stream().findFirst(); } @Override public void upsert(LicenseRecord r) { jdbc.update( "INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (tenant_id) DO UPDATE SET " + " token = EXCLUDED.token, " + " license_id = EXCLUDED.license_id, " + " installed_at = EXCLUDED.installed_at, " + " installed_by = EXCLUDED.installed_by, " + " expires_at = EXCLUDED.expires_at, " + " last_validated_at = EXCLUDED.last_validated_at", r.tenantId(), r.token(), r.licenseId(), Timestamp.from(r.installedAt()), r.installedBy(), Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt()) ); } @Override public int touchValidated(String tenantId, Instant now) { return jdbc.update( "UPDATE license SET last_validated_at = ? WHERE tenant_id = ?", Timestamp.from(now), tenantId); } @Override public int delete(String tenantId) { return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId); } } ``` - [ ] **Step 11.5: Wire the bean (temporary placement; Task 14 consolidates)** In `cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java`, add: ```java @Bean public com.cameleer.server.app.license.LicenseRepository licenseRepository( org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) { return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate); } ``` - [ ] **Step 11.6: Run IT** Run: `mvn -pl cameleer-server-app verify -Dit.test=PostgresLicenseRepositoryIT` Expected: PASS, 2/2. - [ ] **Step 11.7: Commit** ```bash git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java git commit -m "$(cat <<'EOF' feat(license): PostgresLicenseRepository + LicenseRecord JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch updates only last_validated_at, delete is provided for future operator-clear flow (not exposed as REST in v1). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 12: `AuditCategory.LICENSE` **Why:** Spec §6.5 — every license install/replace/reject and every cap rejection writes an audit row under a dedicated category. **Files:** - Modify: the file that declares `AuditCategory` (find via `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src`) - [ ] **Step 12.1: Locate the enum** Run: `grep -rn "enum AuditCategory" cameleer-server-core/src cameleer-server-app/src` Note the file path; expected location is core (per `.claude/rules/core-classes.md`). - [ ] **Step 12.2: Add `LICENSE` to the enum** ```java public enum AuditCategory { INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, DEPLOYMENT, LICENSE } ``` (Insert `LICENSE` last to avoid renumbering existing values if the enum is persisted by ordinal — verify quickly: `grep -n "ordinal\|name()" $(grep -rl "AuditCategory" cameleer-server-app/src) | head`. The category column is stored by `name()` in `audit_log`; appending is safe.) - [ ] **Step 12.3: Build** Run: `mvn -pl cameleer-server-core test` Expected: PASS. - [ ] **Step 12.4: Commit** ```bash git add -u git commit -m "$(cat <<'EOF' feat(license): add AuditCategory.LICENSE Tasks downstream (LicenseService, LicenseEnforcer) audit under this category for install_license / replace_license / reject_license / revalidate_license / cap_exceeded actions. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 13: `LicenseChangedEvent` + `LicenseService` **Why:** Spec §6.3 — single service that mediates DB ↔ gate, publishes events on every change, and audits. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java` - [ ] **Step 13.1: Create the event** ```java 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); } } ``` - [ ] **Step 13.2: Write failing test** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.license.*; 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.Mockito.*; 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).record(eq(AuditCategory.LICENSE), eq("install_license"), any(), any()); } @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).record(eq(AuditCategory.LICENSE), eq("reject_license"), any(), any()); } @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).record(eq(AuditCategory.LICENSE), eq("replace_license"), any(), any()); } } ``` - [ ] **Step 13.3: Run — expect failure** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` Expected: FAIL — class missing. - [ ] **Step 13.4: Implement `LicenseService.java`** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.admin.AuditCategory; 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; import java.util.UUID; 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). 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 payload = new LinkedHashMap<>(); payload.put("reason", reason); payload.put("source", source); audit.record(AuditCategory.LICENSE, "reject_license", installedBy, payload); 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 payload = new LinkedHashMap<>(); payload.put("licenseId", info.licenseId().toString()); payload.put("expiresAt", info.expiresAt().toString()); payload.put("installedBy", installedBy); payload.put("source", source); if (existing.isPresent()) { payload.put("previousLicenseId", existing.get().licenseId().toString()); audit.record(AuditCategory.LICENSE, "replace_license", installedBy, payload); } else { audit.record(AuditCategory.LICENSE, "install_license", installedBy, payload); } 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()); // Promote into gate in case it was marked INVALID for a transient reason gate.load(info); events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); } catch (Exception e) { String reason = e.getMessage(); gate.markInvalid(reason); Map payload = new LinkedHashMap<>(); payload.put("licenseId", persisted.get().licenseId().toString()); payload.put("reason", reason); audit.record(AuditCategory.LICENSE, "revalidate_license", "system", payload); events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); log.error("Revalidation failed: {}", reason); } } public String getTenantId() { return tenantId; } } ``` If the existing `AuditService.record(...)` signature differs, adapt the call sites in this class to match. Run `grep -n "void record" $(grep -rl "interface AuditService" cameleer-server-core/src)` to confirm. - [ ] **Step 13.5: Run unit test** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseServiceTest` Expected: PASS, 3/3. - [ ] **Step 13.6: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license git commit -m "$(cat <<'EOF' feat(license): LicenseService + LicenseChangedEvent Single mediation point for token install/replace/revalidate. Audits under AuditCategory.LICENSE, persists to PG, mutates the LicenseGate, and publishes LicenseChangedEvent so downstream listeners (RetentionPolicyApplier, LicenseMetrics) react uniformly. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 14: Refactor `LicenseBeanConfig` boot order; wire `LicenseService` **Why:** Spec §6.2 — env > file > DB; emit one `LicenseChangedEvent` on boot. **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` - [ ] **Step 14.1: Run gitnexus impact** ``` gitnexus_impact({target: "LicenseBeanConfig", direction: "upstream"}) ``` - [ ] **Step 14.2: Replace `LicenseBeanConfig.java`** ```java 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.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.util.Optional; @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; @Value("${cameleer.server.license.file:}") private String licenseFile; @Value("${cameleer.server.license.publickey:}") private String licensePublicKey; @Bean public LicenseGate licenseGate() { return new LicenseGate(); } @Bean public LicenseValidator licenseValidator() { if (licensePublicKey == null || licensePublicKey.isBlank()) { log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID"); // A non-functional validator that always throws so install() routes to INVALID. return new LicenseValidator( "MCowBQYDK2VwAyEA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", tenantId) { @Override public com.cameleer.server.core.license.LicenseInfo validate(String token) { throw new IllegalStateException("license public key not configured"); } }; } 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); } 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); } } } ``` The anonymous-subclass trick for the absent-key case avoids surfacing a separate "no validator" bean type; `install()` will catch its exception and route to INVALID. If you prefer cleaner architecture, introduce a `LicenseValidator.alwaysFails(reason)` static factory in core — but that's a follow-up. - [ ] **Step 14.3: Build** Run: `mvn -pl cameleer-server-app test -DskipITs` Expected: PASS — existing tests still green. - [ ] **Step 14.4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java git commit -m "$(cat <<'EOF' feat(license): wire LicenseService into boot order (env > file > DB) LicenseBootLoader @PostConstruct calls LicenseService.loadInitial, which delegates to install() so env-var/file/DB paths share a single audit + event-publish code path. A missing public key now produces an always-failing validator so loaded tokens route to INVALID instead of being silently ignored. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 15: `LicenseCapExceededException`, `LicenseMessageRenderer`, `LicenseExceptionAdvice` **Why:** Spec §4 — typed exception + per-state rendered message + global 403 mapping. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseCapExceededException.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMessageRenderer.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseExceptionAdvice.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMessageRendererTest.java` - [ ] **Step 15.1: Create the exception** ```java package com.cameleer.server.app.license; public class LicenseCapExceededException extends RuntimeException { private final String limitKey; private final long current; private final long cap; public LicenseCapExceededException(String limitKey, long current, long cap) { super("license cap reached: " + limitKey + " current=" + current + " cap=" + cap); this.limitKey = limitKey; this.current = current; this.cap = cap; } public String limitKey() { return limitKey; } public long current() { return current; } public long cap() { return cap; } } ``` - [ ] **Step 15.2: Write failing test for `LicenseMessageRenderer`** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.server.core.license.LicenseState; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class LicenseMessageRendererTest { @Test void absent_message() { var msg = LicenseMessageRenderer.forCap(LicenseState.ABSENT, null, "max_apps", 0, 3); assertThat(msg).contains("No license").contains("max_apps").contains("3"); } @Test void active_message() { LicenseInfo info = info(Instant.now().plusSeconds(86400 * 100), 0); var msg = LicenseMessageRenderer.forCap(LicenseState.ACTIVE, info, "max_apps", 50, 50); assertThat(msg).contains("cap reached").contains("50"); } @Test void grace_message_includesDayCount() { LicenseInfo info = info(Instant.now().minusSeconds(86400L * 5), 30); var msg = LicenseMessageRenderer.forCap(LicenseState.GRACE, info, "max_apps", 10, 10); assertThat(msg).contains("expired").contains("5").contains("grace"); } @Test void expired_message_explainsRevert() { LicenseInfo info = info(Instant.now().minusSeconds(86400L * 60), 30); var msg = LicenseMessageRenderer.forCap(LicenseState.EXPIRED, info, "max_apps", 40, 3); assertThat(msg).contains("expired").contains("default tier").contains("3"); } @Test void invalid_message_includesReason() { var msg = LicenseMessageRenderer.forCap(LicenseState.INVALID, null, "max_apps", 0, 3, "signature failed"); assertThat(msg).contains("rejected").contains("signature failed"); } private LicenseInfo info(Instant exp, int graceDays) { return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), Instant.now().minusSeconds(86400L * 365), exp, graceDays); } } ``` - [ ] **Step 15.3: Run — expect failure** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` Expected: FAIL. - [ ] **Step 15.4: Implement `LicenseMessageRenderer.java`** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.server.core.license.LicenseState; import java.time.Duration; import java.time.Instant; public final class LicenseMessageRenderer { private LicenseMessageRenderer() {} public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap) { return forCap(state, info, limit, current, cap, null); } public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap, String invalidReason) { switch (state) { case ABSENT: return "No license installed: default tier applies (cap = " + cap + " for " + limit + "). Install a license to raise this."; case ACTIVE: return "License cap reached: " + limit + " = " + cap + ". Current usage is " + current + ". Contact your vendor to raise the cap."; case GRACE: { long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); long graceRemaining = info == null ? 0 : Math.max(0, info.gracePeriodDays() - expiredDaysAgo); return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period " + "(ends in " + graceRemaining + " days). Cap unchanged at " + cap + ". Renew before grace ends."; } case EXPIRED: { long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt()); return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier (cap = " + cap + " for " + limit + "). Current usage is " + current + ". Renew the license to lift the cap."; } case INVALID: return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason) + "): default tier applies (cap = " + cap + " for " + limit + "). Fix the license to raise this."; default: return "License cap reached: " + limit + " = " + cap; } } private static long daysSince(Instant t) { return Math.max(0, Duration.between(t, Instant.now()).toDays()); } } ``` - [ ] **Step 15.5: Run renderer test** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseMessageRendererTest` Expected: PASS, 5/5. - [ ] **Step 15.6: Implement `LicenseExceptionAdvice.java`** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseInfo; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import java.util.LinkedHashMap; import java.util.Map; @ControllerAdvice public class LicenseExceptionAdvice { private final LicenseGate gate; public LicenseExceptionAdvice(LicenseGate gate) { this.gate = gate; } @ExceptionHandler(LicenseCapExceededException.class) public ResponseEntity> handle(LicenseCapExceededException e) { var state = gate.getState(); LicenseInfo info = gate.getCurrent(); String reason = gate.getInvalidReason(); Map body = new LinkedHashMap<>(); body.put("error", "license cap reached"); body.put("limit", e.limitKey()); body.put("current", e.current()); body.put("cap", e.cap()); body.put("state", state.name()); body.put("message", LicenseMessageRenderer.forCap(state, info, e.limitKey(), e.current(), e.cap(), reason)); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); } } ``` - [ ] **Step 15.7: Build** Run: `mvn -pl cameleer-server-app test -DskipITs` Expected: PASS. - [ ] **Step 15.8: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license git commit -m "$(cat <<'EOF' feat(license): cap-exceeded exception + state-aware message renderer LicenseCapExceededException + @ControllerAdvice mapping to 403 with a body that includes state, limit, current, cap, and a per-state human message templated by LicenseMessageRenderer (covers ABSENT/ACTIVE/ GRACE/EXPIRED/INVALID with day counts and reason). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 16: `LicenseEnforcer` **Why:** Spec §4 — single entry point for cap checks. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java` - [ ] **Step 16.1: Write failing test** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseInfo; import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class LicenseEnforcerTest { @Test void underCap_passes() { LicenseGate gate = new LicenseGate(); gate.load(license(Map.of("max_apps", 10), 0)); new LicenseEnforcer(gate).assertWithinCap("max_apps", 9, 1); } @Test void atCap_throws() { LicenseGate gate = new LicenseGate(); gate.load(license(Map.of("max_apps", 10), 0)); assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 10, 1)) .isInstanceOf(LicenseCapExceededException.class) .hasMessageContaining("max_apps"); } @Test void absent_usesDefaultTier() { LicenseGate gate = new LicenseGate(); // default max_apps = 3; current 3 + 1 > 3 → reject assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 3, 1)) .isInstanceOf(LicenseCapExceededException.class); } @Test void unknownLimitKey_throwsIllegalArgument() { LicenseGate gate = new LicenseGate(); assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_xyz", 0, 1)) .isInstanceOf(IllegalArgumentException.class); } private LicenseInfo license(Map limits, int grace) { return new LicenseInfo(UUID.randomUUID(), "acme", null, limits, Instant.now(), Instant.now().plusSeconds(86400), grace); } } ``` - [ ] **Step 16.2: Run — expect failure** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` Expected: FAIL. - [ ] **Step 16.3: Implement `LicenseEnforcer.java`** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseLimits; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.stereotype.Component; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @Component public class LicenseEnforcer { private final LicenseGate gate; private final MeterRegistry meters; private final ConcurrentMap rejectionCounters = new ConcurrentHashMap<>(); public LicenseEnforcer(LicenseGate gate, MeterRegistry meters) { this.gate = gate; this.meters = meters; } public LicenseEnforcer(LicenseGate gate) { this(gate, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()); } public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) { LicenseLimits effective = gate.getEffectiveLimits(); int cap = effective.get(limitKey); // throws IAE if unknown key if (currentUsage + requestedDelta > cap) { rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder("cameleer_license_cap_rejections_total") .tag("limit", k).register(meters)).increment(); throw new LicenseCapExceededException(limitKey, currentUsage + requestedDelta, cap); } } } ``` - [ ] **Step 16.4: Run tests** Run: `mvn -pl cameleer-server-app test -Dtest=LicenseEnforcerTest` Expected: PASS, 4/4. - [ ] **Step 16.5: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/license cameleer-server-app/src/test/java/com/cameleer/server/app/license git commit -m "$(cat <<'EOF' feat(license): LicenseEnforcer single entry point assertWithinCap consults LicenseGate.getEffectiveLimits, throws LicenseCapExceededException on overflow, increments cameleer_license_cap_rejections_total{limit=...} for telemetry. Unknown limit keys are programmer errors (IllegalArgumentException), not 403s. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 17: `LicenseUsageReader` **Why:** Spec §5 — reads counts/sums for the `/usage` endpoint. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java` - [ ] **Step 17.1: Implement `LicenseUsageReader.java`** ```java package com.cameleer.server.app.license; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; import java.util.Map; @Component public class LicenseUsageReader { private final JdbcTemplate jdbc; public LicenseUsageReader(JdbcTemplate jdbc) { this.jdbc = jdbc; } public Map snapshot() { Map out = new LinkedHashMap<>(); out.put("max_environments", count("environments")); out.put("max_apps", count("apps")); out.put("max_users", count("users")); out.put("max_outbound_connections", count("outbound_connections")); out.put("max_alert_rules", count("alert_rules")); // max_agents is in-memory; AgentRegistryService injects via callback below. // Compute aggregates: sum across non-stopped deployments. Container config is JSONB. Map compute = jdbc.queryForObject( "SELECT " + " COALESCE(SUM(replicas * cpu_millis), 0) AS cpu, " + " COALESCE(SUM(replicas * memory_mb), 0) AS mem, " + " COALESCE(SUM(replicas), 0) AS reps " + "FROM ( " + " SELECT " + " COALESCE((d.deployed_config_snapshot->>'replicas')::int, 1) AS replicas, " + " COALESCE((d.deployed_config_snapshot->>'cpuLimit')::int, 0) AS cpu_millis, " + " COALESCE((d.deployed_config_snapshot->>'memoryLimitMb')::int, 0) AS memory_mb " + " FROM deployments d " + " WHERE d.status IN ('STARTING','RUNNING','DEGRADED','STOPPING') " + ") s", (rs, n) -> Map.of( "max_total_cpu_millis", rs.getLong("cpu"), "max_total_memory_mb", rs.getLong("mem"), "max_total_replicas", rs.getLong("reps") )); out.putAll(compute); return out; } public long agentCount(int liveAgents) { return liveAgents; } private long count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); } } ``` If `deployed_config_snapshot` does not contain numeric `replicas/cpuLimit/memoryLimitMb` fields in your schema (introduced in V3), confirm with `grep -n deployed_config_snapshot cameleer-server-app/src/main/java`. Adjust the JSON paths to match `DeploymentConfigSnapshot` field names. - [ ] **Step 17.2: Write IT (boots Spring + Postgres)** ```java package com.cameleer.server.app.license; import com.cameleer.server.app.AbstractPostgresIT; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.assertThat; class LicenseUsageReaderIT extends AbstractPostgresIT { @Autowired LicenseUsageReader reader; @Test void emptyDb_returnsZeros() { var snap = reader.snapshot(); assertThat(snap.get("max_apps")).isEqualTo(0L); assertThat(snap.get("max_environments")).isLessThanOrEqualTo(1L); // V1 seeds default env assertThat(snap.get("max_total_cpu_millis")).isEqualTo(0L); } } ``` - [ ] **Step 17.3: Run IT** Run: `mvn -pl cameleer-server-app verify -Dit.test=LicenseUsageReaderIT` Expected: PASS. - [ ] **Step 17.4: Commit** ```bash git add cameleer-server-app/src/{main,test}/java/com/cameleer/server/app/license git commit -m "$(cat <<'EOF' feat(license): LicenseUsageReader aggregates current usage One COUNT per entity table; one SUM-grouped query over non-stopped deployments for compute caps. agentCount is fed in by the controller since it's an in-memory registry value, not a DB row. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 18: Wire enforcement — `max_environments` in `EnvironmentService.create` **Why:** Spec §4.1. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` (inject enforcer) - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java` - [ ] **Step 18.1: Run gitnexus impact** ``` gitnexus_impact({target: "EnvironmentService", direction: "upstream"}) gitnexus_impact({target: "create", direction: "upstream"}) -- (filter to EnvironmentService.create in the report) ``` - [ ] **Step 18.2: Add a callback hook to `EnvironmentService`** `EnvironmentService` lives in `core` (no Spring) — pass the enforcer in via constructor as a functional interface to keep core decoupled: ```java @FunctionalInterface public interface CreateGuard { void check(long current); // throws if cap exceeded CreateGuard NOOP = c -> {}; } ``` Place this `CreateGuard` interface in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java`. Modify `EnvironmentService`: ```java public class EnvironmentService { private final EnvironmentRepository repo; private final CreateGuard createGuard; public EnvironmentService(EnvironmentRepository repo) { this(repo, CreateGuard.NOOP); } public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) { this.repo = repo; this.createGuard = createGuard; } public UUID create(String slug, String displayName, boolean production) { createGuard.check(repo.count()); // ... existing body unchanged } } ``` If `EnvironmentRepository` lacks `count()`, add `long count();` to the interface and implement it in `PostgresEnvironmentRepository` as `jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class)`. - [ ] **Step 18.3: Wire in `RuntimeBeanConfig` (or wherever `EnvironmentService` is constructed)** Locate the `@Bean public EnvironmentService environmentService(...)` method and replace with: ```java @Bean public EnvironmentService environmentService(EnvironmentRepository repo, com.cameleer.server.app.license.LicenseEnforcer enforcer) { return new EnvironmentService(repo, current -> enforcer.assertWithinCap("max_environments", current, 1)); } ``` - [ ] **Step 18.4: Write failing IT** ```java package com.cameleer.server.app.license; import com.cameleer.server.app.AbstractPostgresIT; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; class EnvironmentCapEnforcementIT extends AbstractPostgresIT { @Autowired MockMvc mvc; @Test void createsUpToCap_thenReturns403WithStateAndMessage() throws Exception { // Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects. mvc.perform(post("/api/v1/admin/environments") .with(user("admin").roles("ADMIN")) .contentType(MediaType.APPLICATION_JSON) .content("{\"slug\":\"prod\",\"displayName\":\"Prod\",\"production\":true}")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error").value("license cap reached")) .andExpect(jsonPath("$.limit").value("max_environments")) .andExpect(jsonPath("$.cap").value(1)) .andExpect(jsonPath("$.state").value("ABSENT")) .andExpect(jsonPath("$.message").exists()); } } ``` - [ ] **Step 18.5: Run — expect failure first, then PASS after implementation** Run: `mvn -pl cameleer-server-app verify -Dit.test=EnvironmentCapEnforcementIT` Expected: PASS once Steps 18.2–18.3 are applied. - [ ] **Step 18.6: Commit** ```bash git add cameleer-server-core/src cameleer-server-app/src git commit -m "$(cat <<'EOF' feat(license): enforce max_environments at EnvironmentService.create Adds CreateGuard functional interface to core (preserves no-Spring boundary), wires LicenseEnforcer into the EnvironmentService bean in RuntimeBeanConfig. EnvironmentRepository.count() added. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 19: Wire enforcement — `max_apps` in `AppService.createApp` **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java` - [ ] **Step 19.1: Run gitnexus impact** on `AppService` and `createApp`. - [ ] **Step 19.2: Add CreateGuard to AppService** (same pattern as Task 18). Add `long count();` to `AppRepository` if missing. - [ ] **Step 19.3: Update `createApp`** ```java public UUID createApp(UUID environmentId, String slug, String displayName) { createGuard.check(repo.count()); // ... existing body } ``` - [ ] **Step 19.4: Wire bean** ```java @Bean public AppService appService(AppRepository repo, com.cameleer.server.app.license.LicenseEnforcer enforcer) { return new AppService(repo, current -> enforcer.assertWithinCap("max_apps", current, 1)); } ``` - [ ] **Step 19.5: Write IT** — analogous to 18.4, posting to `/api/v1/environments/default/apps` with body `{"slug":"a1","displayName":"A1"}`. Default cap is 3; loop create three then expect 403 on the fourth. - [ ] **Step 19.6: Run + Commit** ```bash mvn -pl cameleer-server-app verify -Dit.test=AppCapEnforcementIT git add cameleer-server-core/src cameleer-server-app/src git commit -m "feat(license): enforce max_apps at AppService.createApp Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 20: Wire enforcement — `max_agents` in `AgentRegistryService.register` **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java` - Modify: bean config that constructs the registry - Modify: `AgentRegistrationController` to translate the exception to the same 403 format - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java` - [ ] **Step 20.1: Run gitnexus impact** on `AgentRegistryService.register`. - [ ] **Step 20.2: Inject CreateGuard into `AgentRegistryService`** `register(...)` currently inserts into the in-memory map. Insert at the top: ```java createGuard.check(liveAgentCount()); ``` Where `liveAgentCount()` is `(int) registry.values().stream().filter(a -> a.state() == AgentState.LIVE).count()`. - [ ] **Step 20.3: Wire bean** — `enforcer.assertWithinCap("max_agents", current, 1)`. - [ ] **Step 20.4: `AgentRegistrationController` exception handling** `AgentRegistrationController.register` currently returns the registered `AgentInfo`. Wrap the registry call in try/catch — if `LicenseCapExceededException` escapes, the global `LicenseExceptionAdvice` returns a 403 automatically; just verify behaviour by catching nothing here. - [ ] **Step 20.5: Write IT** — load a license with `max_agents=2`, register two agents, expect 403 on third with `state` and `limit=max_agents`. - [ ] **Step 20.6: Run + Commit** as in Task 19. --- ## Task 21: Wire enforcement — `max_users` (admin + OIDC auto-signup) **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/UserCapEnforcementIT.java` - [ ] **Step 21.1: Run gitnexus impact** on `createUser` and `OidcAuthController.callback`. - [ ] **Step 21.2: Inject `LicenseEnforcer` and `UserRepository.count()`** In `UserAdminController.createUser` (top of method, before any insert): ```java enforcer.assertWithinCap("max_users", userRepository.count(), 1); ``` In `OidcAuthController.callback` (auto-signup branch, just before creating the new user): ```java if (existingUser.isEmpty() && config.autoSignup()) { enforcer.assertWithinCap("max_users", userRepository.count(), 1); // ... existing create } ``` If `UserRepository` lacks `count()`, add `long count();` and implement. - [ ] **Step 21.3: Write IT** — login `admin` (V1 seeds it), create users until cap is hit, expect 403 with state + message; verify the `cap_exceeded` audit row exists by querying `/api/v1/admin/audit?category=LICENSE&action=cap_exceeded`. - [ ] **Step 21.4: Wire `cap_exceeded` audit emission** The advice returns 403 but does not audit. To meet spec §6.5, intercept in `LicenseEnforcer`: ```java public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, AuditService audit /* nullable in unit tests */) { // ... store all } ``` In `assertWithinCap` after computing the rejection but before throwing: ```java if (audit != null) { Map payload = new LinkedHashMap<>(); payload.put("limit", limitKey); payload.put("current", currentUsage + requestedDelta); payload.put("cap", cap); payload.put("state", gate.getState().name()); String requester = currentRequester(); // helper that reads SecurityContextHolder audit.record(AuditCategory.LICENSE, "cap_exceeded", requester, payload); } ``` `currentRequester()` strips the `user:` prefix per the existing convention in `app-classes.md`. Add a 3-arg constructor and update existing 1-arg/2-arg constructors and tests accordingly. The 1-arg test constructor should pass `null` for audit. - [ ] **Step 21.5: Run + Commit**. --- ## Task 22: Wire enforcement — `max_outbound_connections` in `OutboundConnectionServiceImpl.create` **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java` - [ ] **Step 22.1: gitnexus impact** on `OutboundConnectionServiceImpl.create`. - [ ] **Step 22.2: Inject enforcer + repo count**. At top of `create(...)`: ```java enforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1); ``` - [ ] **Step 22.3: IT** — default cap=1; create one then expect 403 on second. - [ ] **Step 22.4: Run + Commit**. --- ## Task 23: Wire enforcement — `max_alert_rules` in `AlertRuleController.create` **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java` (path may vary; locate via `gitnexus_context({name: "AlertRuleController"})`) - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java` - [ ] **Step 23.1: gitnexus impact** on `AlertRuleController.create`. - [ ] **Step 23.2: Inject enforcer + `AlertRuleRepository.count()`**. At top of POST handler: ```java enforcer.assertWithinCap("max_alert_rules", alertRuleRepository.count(), 1); ``` - [ ] **Step 23.3: IT** — default cap=2; create two then expect 403 on third. - [ ] **Step 23.4: Run + Commit**. --- ## Task 24: Wire enforcement — compute caps in `DeploymentExecutor.PRE_FLIGHT` **Why:** Spec §4.1 — sum cpu/memory/replicas across non-stopped deployments + new request must fit within caps. **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java` - [ ] **Step 24.1: gitnexus impact** on `DeploymentExecutor` and the `PRE_FLIGHT` stage method. - [ ] **Step 24.2: Add a compute aggregator (lives on `LicenseUsageReader` from Task 17)** Add to `LicenseUsageReader`: ```java public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {} public ComputeUsage computeUsage() { var snap = snapshot(); return new ComputeUsage( snap.getOrDefault("max_total_cpu_millis", 0L), snap.getOrDefault("max_total_memory_mb", 0L), snap.getOrDefault("max_total_replicas", 0L)); } ``` - [ ] **Step 24.3: In `DeploymentExecutor.preFlight(...)`** After resolving config but before any container creation: ```java var resolved = config; // ResolvedContainerConfig already computed int reqCpu = resolved.cpuLimit() == null ? 0 : resolved.cpuLimit(); int reqMem = resolved.memoryLimitMb() == null ? 0 : resolved.memoryLimitMb(); int reqReps = resolved.replicas(); var usage = usageReader.computeUsage(); enforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps); enforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps); enforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps); ``` The exception bubbles, the executor catches, marks deployment FAILED with the validator message, audit row already covered by §6.5 cap_exceeded. - [ ] **Step 24.4: IT** — create a deployment with `cpuLimit=3000` while default cap is 2000; expect deploy to land in FAILED state with the failure reason containing `max_total_cpu_millis`. - [ ] **Step 24.5: Run + Commit**. --- ## Task 25: Wire enforcement — retention caps + `max_jar_retention_count` **Why:** Spec §4.1. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java` (add `update(...)` retention validation) - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java` (PUT /jar-retention) - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java` - [ ] **Step 25.1: Add retention caps to update path** In `EnvironmentService.update(...)` (or the Postgres repo's update query), validate before save: ```java var enforcer = retentionEnforcer; // injected functional handle enforcer.checkRetention("max_execution_retention_days", req.executionRetentionDays()); enforcer.checkRetention("max_log_retention_days", req.logRetentionDays()); enforcer.checkRetention("max_metric_retention_days", req.metricRetentionDays()); ``` `RetentionEnforcer` is a small wrapper: ```java @FunctionalInterface public interface RetentionEnforcer { void checkRetention(String key, int requested); } ``` In the bean wiring, supply: ```java RetentionEnforcer re = (k, v) -> { int cap = enforcer.gate().getEffectiveLimits().get(k); if (v > cap) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "retention " + k + "=" + v + " exceeds license cap " + cap); }; ``` (Add `LicenseGate gate()` accessor to `LicenseEnforcer` so the wrapper can read effective limits, OR pass `LicenseGate` directly.) - [ ] **Step 25.2: Apply to `EnvironmentAdminController.PUT /jar-retention`** ```java int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count"); if (request.jarRetentionCount() > cap) { throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "jarRetentionCount " + request.jarRetentionCount() + " exceeds license cap " + cap); } ``` - [ ] **Step 25.3: IT** — PUT a retention of 30 days into the default-tier server (cap=1); expect 422. - [ ] **Step 25.4: Run + Commit**. --- ## Task 26: Add `Environment` retention fields + repository changes **Why:** Spec §4.2 — store + read the new columns on `environments`. **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java` - Modify: any DTO/controller that returns env fields to the UI (`EnvironmentAdminController`) - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresEnvironmentRepositoryIT.java` (extend with new columns) - [ ] **Step 26.1: Update the record** Add three `int` fields after `jarRetentionCount`: ```java public record Environment( UUID id, String slug, String displayName, boolean production, boolean enabled, ContainerConfig defaultContainerConfig, int jarRetentionCount, String color, Instant createdAt, int executionRetentionDays, int logRetentionDays, int metricRetentionDays ) { /* validators preserved */ } ``` Update all `new Environment(...)` call sites — there will be several. Use `gitnexus_context({name: "Environment"})` to enumerate. - [ ] **Step 26.2: Update `PostgresEnvironmentRepository`** — `INSERT`, `UPDATE`, and the `RowMapper` to include the three new columns. - [ ] **Step 26.3: Surface on the wire** Add the three fields to the `EnvironmentDto` (or whatever the admin controller returns) and the `UpdateEnvironmentRequest`. - [ ] **Step 26.4: Run unit + ITs** `mvn -pl cameleer-server-app test -DskipITs && mvn -pl cameleer-server-app verify -Dit.test=PostgresEnvironmentRepositoryIT` - [ ] **Step 26.5: Commit**. --- ## Task 27: `RetentionPolicyApplier` — runtime ClickHouse TTL recompute **Why:** Spec §4.3. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/RetentionPolicyApplier.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionPolicyApplierTest.java` - [ ] **Step 27.1: Implement applier** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.EnvironmentRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.event.EventListener; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import java.util.Map; @Component public class RetentionPolicyApplier { private static final Logger log = LoggerFactory.getLogger(RetentionPolicyApplier.class); private static final Map TABLE_TO_LIMIT = Map.of( "executions", "max_execution_retention_days", "processors", "max_execution_retention_days", "logs", "max_log_retention_days", "metrics", "max_metric_retention_days", "agent_events", "max_metric_retention_days", "route_diagrams", "max_metric_retention_days" ); private final LicenseGate gate; private final EnvironmentRepository envRepo; private final JdbcTemplate clickhouseJdbc; public RetentionPolicyApplier(LicenseGate gate, EnvironmentRepository envRepo, @Qualifier("clickhouseJdbcTemplate") JdbcTemplate clickhouseJdbc) { this.gate = gate; this.envRepo = envRepo; this.clickhouseJdbc = clickhouseJdbc; } @EventListener(LicenseChangedEvent.class) @Async public void onLicenseChanged(LicenseChangedEvent ignored) { var limits = gate.getEffectiveLimits(); for (Environment env : envRepo.findAll()) { apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "executions"); apply(env, limits.get("max_execution_retention_days"), env.executionRetentionDays(), "processors"); apply(env, limits.get("max_log_retention_days"), env.logRetentionDays(), "logs"); apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "metrics"); apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "agent_events"); apply(env, limits.get("max_metric_retention_days"), env.metricRetentionDays(), "route_diagrams"); } } private void apply(Environment env, int cap, int configured, String table) { int effective = Math.min(cap, configured); // ClickHouse partition is by (tenant_id, toYYYYMM(timestamp)); per-env TTL is enforced via TTL clause that reads environment column. // We use a single ALTER TABLE per call; ClickHouse will only re-evaluate on its next merge. String sql = "ALTER TABLE " + table + " MODIFY TTL timestamp + INTERVAL " + effective + " DAY WHERE environment = '" + env.slug().replace("'", "''") + "'"; try { clickhouseJdbc.execute(sql); log.info("Applied TTL: table={} env={} days={}", table, env.slug(), effective); } catch (Exception e) { log.warn("Failed to apply TTL for {}/{}: {}", table, env.slug(), e.getMessage()); } } } ``` NB: Per-env TTL via `WHERE environment = '...'` requires ClickHouse 22.x+. If the project's CH does not support per-env TTL, fall back to a global TTL = `min(licenseCap, max(env.configured))`. Verify against `init.sql`. - [ ] **Step 27.2: Unit test (mock JdbcTemplate, EnvRepository)** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.*; import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.EnvironmentRepository; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.UUID; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.*; class RetentionPolicyApplierTest { @Test void onChange_emitsAlterPerTablePerEnv() { LicenseGate gate = new LicenseGate(); EnvironmentRepository envRepo = mock(EnvironmentRepository.class); JdbcTemplate ch = mock(JdbcTemplate.class); when(envRepo.findAll()).thenReturn(List.of( new Environment(UUID.randomUUID(), "default", "Default", false, true, null, 3, "slate", Instant.now(), 7, 7, 14) )); new RetentionPolicyApplier(gate, envRepo, ch) .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); verify(ch, atLeast(1)).execute(contains("ALTER TABLE")); } @Test void chFailure_doesNotPropagate() { LicenseGate gate = new LicenseGate(); EnvironmentRepository envRepo = mock(EnvironmentRepository.class); JdbcTemplate ch = mock(JdbcTemplate.class); doThrow(new RuntimeException("ch down")).when(ch).execute(anyString()); when(envRepo.findAll()).thenReturn(List.of( new Environment(UUID.randomUUID(), "default", "Default", false, true, null, 3, "slate", Instant.now(), 7, 7, 14) )); new RetentionPolicyApplier(gate, envRepo, ch) .onLicenseChanged(new LicenseChangedEvent(LicenseState.ABSENT, null)); // no exception } } ``` - [ ] **Step 27.3: Run + Commit**. --- ## Task 28: `LicenseRevalidationJob` **Why:** Spec §6.6. **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRevalidationJob.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseRevalidationJobTest.java` - [ ] **Step 28.1: Implement** ```java package com.cameleer.server.app.license; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class LicenseRevalidationJob { private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class); private final LicenseService svc; public LicenseRevalidationJob(LicenseService svc) { this.svc = svc; } @EventListener(ApplicationReadyEvent.class) public void onStartup() { try { Thread.sleep(60_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } revalidate(); } @Scheduled(cron = "0 0 3 * * *") public void daily() { revalidate(); } private void revalidate() { try { svc.revalidate(); } catch (Exception e) { log.error("Revalidation crashed: {}", e.getMessage()); } } } ``` Ensure `@EnableScheduling` is present in the Spring config (likely in `Application.java` or a common config). - [ ] **Step 28.2: Unit test** ```java package com.cameleer.server.app.license; import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; class LicenseRevalidationJobTest { @Test void daily_callsService() { LicenseService svc = mock(LicenseService.class); new LicenseRevalidationJob(svc).daily(); verify(svc).revalidate(); } } ``` - [ ] **Step 28.3: Run + Commit**. --- ## Task 29: Extend `LicenseAdminController` to delegate to `LicenseService` and return state **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` - [ ] **Step 29.1: gitnexus impact** on `LicenseAdminController`. - [ ] **Step 29.2: Replace controller** ```java @RestController @RequestMapping("/api/v1/admin/license") @PreAuthorize("hasRole('ADMIN')") @Tag(name = "License Admin", description = "License management") public class LicenseAdminController { private final LicenseService licenseService; private final LicenseGate gate; private final LicenseRepository repo; public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) { this.licenseService = svc; this.gate = gate; this.repo = repo; } @GetMapping public ResponseEntity> getCurrent() { Map body = new LinkedHashMap<>(); body.put("state", gate.getState().name()); body.put("invalidReason", gate.getInvalidReason()); body.put("envelope", gate.getCurrent()); // null when ABSENT/INVALID; raw token deliberately omitted repo.findByTenantId(licenseService.getTenantId()).ifPresent(rec -> body.put("lastValidatedAt", rec.lastValidatedAt().toString())); return ResponseEntity.ok(body); } record UpdateLicenseRequest(String token) {} @PostMapping public ResponseEntity update(@RequestBody UpdateLicenseRequest request, Authentication auth) { String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", ""); try { var info = licenseService.install(request.token(), userId, "api"); return ResponseEntity.ok(Map.of( "state", gate.getState().name(), "envelope", info)); } catch (Exception e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } } ``` - [ ] **Step 29.3: Run + Commit**. --- ## Task 30: `LicenseUsageController` **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java` - [ ] **Step 30.1: Implement** ```java @RestController @RequestMapping("/api/v1/admin/license/usage") @PreAuthorize("hasRole('ADMIN')") public class LicenseUsageController { private final LicenseGate gate; private final LicenseUsageReader reader; private final AgentRegistryService agents; // for live agent count private final LicenseService svc; private final LicenseRepository repo; public LicenseUsageController(LicenseGate gate, LicenseUsageReader reader, AgentRegistryService agents, LicenseService svc, LicenseRepository repo) { this.gate = gate; this.reader = reader; this.agents = agents; this.svc = svc; this.repo = repo; } @GetMapping public ResponseEntity> get() { var state = gate.getState(); var info = gate.getCurrent(); var effective = gate.getEffectiveLimits(); var usage = new java.util.HashMap<>(reader.snapshot()); usage.put("max_agents", (long) agents.liveCount()); List> limitRows = new java.util.ArrayList<>(); for (var key : effective.values().keySet()) { Map row = new LinkedHashMap<>(); row.put("key", key); row.put("current", usage.getOrDefault(key, 0L)); row.put("cap", effective.get(key)); row.put("source", info != null && info.limits().containsKey(key) ? "license" : "default"); limitRows.add(row); } Map body = new LinkedHashMap<>(); body.put("state", state.name()); body.put("expiresAt", info == null ? null : info.expiresAt().toString()); body.put("daysRemaining", info == null ? null : java.time.Duration.between(java.time.Instant.now(), info.expiresAt()).toDays()); body.put("gracePeriodDays", info == null ? 0 : info.gracePeriodDays()); body.put("tenantId", info == null ? null : info.tenantId()); body.put("label", info == null ? null : info.label()); repo.findByTenantId(svc.getTenantId()).ifPresent(rec -> body.put("lastValidatedAt", rec.lastValidatedAt().toString())); body.put("message", LicenseMessageRenderer.forState(state, info, gate.getInvalidReason())); body.put("limits", limitRows); return ResponseEntity.ok(body); } } ``` Add a sibling `LicenseMessageRenderer.forState(...)` (parallel to `forCap`) emitting the §5 templates (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID). Add a `liveCount()` method to `AgentRegistryService` if not already exposed. - [ ] **Step 30.2: Write IT** — assert response shape includes `state`, `limits[]` with `key/current/cap/source`, `message`, `lastValidatedAt`. - [ ] **Step 30.3: Run + Commit**. --- ## Task 31: `LicenseMetrics` **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java` - [ ] **Step 31.1: Implement** ```java package com.cameleer.server.app.license; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseState; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.Instant; import java.util.EnumMap; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @Component public class LicenseMetrics { private final LicenseGate gate; private final LicenseUsageReader usage; private final LicenseRepository repo; private final String tenantId; private final Map> stateGauges = new EnumMap<>(LicenseState.class); private final AtomicReference daysRemaining = new AtomicReference<>(0.0); private final AtomicReference validatedAge = new AtomicReference<>(0.0); public LicenseMetrics(LicenseGate gate, LicenseUsageReader usage, LicenseRepository repo, MeterRegistry meters, @org.springframework.beans.factory.annotation.Value("${cameleer.server.tenant.id:default}") String tenantId) { this.gate = gate; this.usage = usage; this.repo = repo; this.tenantId = tenantId; for (var s : LicenseState.values()) { var ref = new AtomicReference<>(0.0); stateGauges.put(s, ref); Gauge.builder("cameleer_license_state", ref, AtomicReference::get) .tag("state", s.name()).register(meters); } Gauge.builder("cameleer_license_days_remaining", daysRemaining, AtomicReference::get).register(meters); Gauge.builder("cameleer_license_last_validated_age_seconds", validatedAge, AtomicReference::get).register(meters); // Per-limit utilisation registered lazily on first compute via tag-aware Gauge.builder + a ConcurrentMap. } @EventListener(LicenseChangedEvent.class) @Scheduled(fixedDelay = 60_000) public void refresh() { var state = gate.getState(); for (var s : LicenseState.values()) { stateGauges.get(s).set(s == state ? 1.0 : 0.0); } var info = gate.getCurrent(); daysRemaining.set(info == null ? -1.0 : (double) Duration.between(Instant.now(), info.expiresAt()).toDays()); repo.findByTenantId(tenantId).ifPresent(rec -> validatedAge.set((double) Duration.between(rec.lastValidatedAt(), Instant.now()).toSeconds())); } } ``` (Per-limit `cameleer_license_limit_utilisation` is omitted from this skeleton to keep the file short — extend with a `Map>` and register one gauge per key on first refresh.) - [ ] **Step 31.2: Run + Commit**. --- ## Task 32: Integration test — `LicenseLifecycleIT` **Why:** Spec §10 — install via env / replace via POST / restart restores from DB / public-key removal → INVALID / daily revalidation updates timestamp. **Files:** - Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseLifecycleIT.java` - [ ] **Step 32.1: Skeleton** ```java class LicenseLifecycleIT extends AbstractPostgresIT { @Autowired MockMvc mvc; @Autowired LicenseService svc; @Autowired LicenseGate gate; @Autowired LicenseRepository repo; @Test void install_persists_andSurvivesGateClear() throws Exception { // Mint a token via LicenseMinter (test pulls in cameleer-license-minter dep — add as test scope) // POST to /api/v1/admin/license; assert state=ACTIVE; clear gate; reload via svc.loadInitial; assert state=ACTIVE again from DB. } @Test void postWithBadSignature_marksInvalid() { // POST a tampered token; expect 400; assert gate.getState() == INVALID; assert audit row exists. } @Test void revalidateAfterPublicKeyChange_marksInvalid() { // Install valid token; rebuild validator with a different public key (or simulate by direct gate.markInvalid via svc.revalidate after corrupting DB token). } } ``` Flesh out each test fully. Add `cameleer-license-minter` as a `test` dependency on `cameleer-server-app/pom.xml` for this IT (acceptable: minter on test classpath only does not leak it into the runtime jar). - [ ] **Step 32.2: Run + Commit**. --- ## Task 33: Integration test — `LicenseEnforcementIT` **Why:** Spec §10 — REST-driven, hits each cap end-to-end + verifies `cap_exceeded` audit + 403 message. **Files:** - Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java` - [ ] **Step 33.1: One @Nested per limit** — install a license raising the cap, drive the REST endpoint until full, assert 403 body shape, query audit endpoint to confirm `cap_exceeded` row. - [ ] **Step 33.2: Run + Commit**. --- ## Task 34: Integration test — `RetentionRuntimeRecomputeIT` **Why:** Spec §4.3. **Files:** - Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java` - [ ] **Step 34.1: Steps** 1. Install license with `max_log_retention_days=30`. 2. Read TTL via `SELECT engine_full FROM system.tables WHERE name='logs'` on the test ClickHouse Testcontainer; assert `INTERVAL 30 DAY`. 3. Replace license with `max_log_retention_days=7`. 4. Re-read TTL; assert `INTERVAL 7 DAY` (allow up to 5s for the @Async event listener; poll). - [ ] **Step 34.2: Run + Commit**. --- ## Task 35: Extend `SchemaBootstrapIT` + regenerate OpenAPI types **Files:** - Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java` - Regen: `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` - [ ] **Step 35.1: Extend `SchemaBootstrapIT`** Add asserts: `license` table exists with all columns including `last_validated_at`; `environments` has 3 retention columns. - [ ] **Step 35.2: Regenerate OpenAPI** Per `CLAUDE.md`: ```bash mvn -pl cameleer-server-app spring-boot:run & # background sleep 30 cd ui && npm run generate-api:live kill %1 ``` (Or use the project's standard procedure if different.) Fix any TypeScript compile errors in the SPA call sites for the modified `/admin/license` GET response shape and the new `/admin/license/usage` endpoint. - [ ] **Step 35.3: Commit** — separate commit for backend, separate for SPA. --- ## Task 36: Update `.claude/rules/` **Why:** Per `CLAUDE.md` — class/API map must stay in sync with the code. **Files:** - Modify: `.claude/rules/core-classes.md` - Modify: `.claude/rules/app-classes.md` - Modify: `.claude/rules/gitnexus.md` (only if symbol counts change after `npx gitnexus analyze`) - [ ] **Step 36.1: Append to `.claude/rules/core-classes.md`** under `## license/` Document: `LicenseInfo` (new shape), `LicenseLimits`, `LicenseValidator(publicKey, expectedTenantId)`, `LicenseGate.{getState,getEffectiveLimits,markInvalid,clear}`, `LicenseStateMachine.classify`, `LicenseState`, `DefaultTierLimits.DEFAULTS`, `Environment` (new retention fields), `CreateGuard`. - [ ] **Step 36.2: Append to `.claude/rules/app-classes.md`** Document: `LicenseService`, `LicenseRepository`/`PostgresLicenseRepository`/`LicenseRecord`, `LicenseEnforcer.assertWithinCap`, `LicenseUsageReader.{snapshot, computeUsage, agentCount}`, `LicenseCapExceededException`, `LicenseExceptionAdvice` (403 mapping), `LicenseMessageRenderer`, `LicenseRevalidationJob`, `RetentionPolicyApplier`, `LicenseMetrics`, `LicenseUsageController` (`GET /api/v1/admin/license/usage`), revised `LicenseAdminController`, `AuditCategory.LICENSE`. Add `/api/v1/admin/license/usage` to the **Admin (cross-env, flat)** sub-section. - [ ] **Step 36.3: Run gitnexus analyze** ```bash npx gitnexus analyze --embeddings ``` Update `.claude/rules/gitnexus.md` with the new symbol/relationship counts shown by the tool. - [ ] **Step 36.4: Commit** ```bash git add .claude/rules git commit -m "$(cat <<'EOF' docs(rules): document license enforcement classes + endpoints Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Self-review checklist (run after Task 36 completes) - [ ] All 13 spec limit keys have an enforcement task and an IT assertion. - [ ] `LicenseChangedEvent` is published from boot, install, and revalidation paths. - [ ] `RetentionPolicyApplier` is invoked on every change (boot + replace + revalidation). - [ ] `cap_exceeded` audit row carries `state`. - [ ] 403 body contains `state` + `message`. - [ ] OpenAPI types regenerated; no stale TS compile errors. - [ ] `.claude/rules/*.md` updated in the same series of commits. - [ ] Default-tier server cannot create a 2nd environment, 4th app, 6th agent, etc., per §3.2. - [ ] `cameleer-server-app` does NOT compile-depend on `cameleer-license-minter` (verify `mvn -pl cameleer-server-app dependency:tree | grep license-minter` → empty).