# Plan 2: Server-Side License Validation > **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:** Add Ed25519-signed license JWT validation to the server, enabling feature gating for MOAT features (debugger, lineage, correlation) by tier. **Architecture:** The SaaS generates Ed25519-signed license JWTs containing tier, features, limits, and expiry. The server validates the license on startup (from env var or file) or at runtime (via admin API). A `LicenseGate` service checks whether a feature is enabled before serving gated endpoints. The server's existing Ed25519 infrastructure (JDK 17 `java.security`) is reused for verification. In standalone mode without a license, all features are available (open/dev mode). **Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ **Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server` --- ## File Map ### New Files - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` - `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` - `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` ### Modified Files - `cameleer-server-app/src/main/resources/application.yml` — add license config properties --- ### Task 1: Core Domain — LicenseInfo, Feature Enum **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java` - [ ] **Step 1: Create Feature enum** ```java package com.cameleer.server.core.license; public enum Feature { topology, lineage, correlation, debugger, replay } ``` - [ ] **Step 2: Create LicenseInfo record** ```java package com.cameleer.server.core.license; import java.time.Instant; import java.util.Map; import java.util.Set; public record LicenseInfo( String tier, Set features, Map limits, Instant issuedAt, Instant expiresAt ) { public boolean isExpired() { return expiresAt != null && Instant.now().isAfter(expiresAt); } public boolean hasFeature(Feature feature) { return features.contains(feature); } public int getLimit(String key, int defaultValue) { return limits.getOrDefault(key, defaultValue); } /** Open license — all features enabled, no limits. Used when no license is configured. */ public static LicenseInfo open() { return new LicenseInfo("open", Set.of(Feature.values()), Map.of(), Instant.now(), null); } } ``` - [ ] **Step 3: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java git commit -m "feat: add LicenseInfo and Feature domain model" ``` --- ### Task 2: LicenseValidator — Ed25519 JWT Verification **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java` - Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java` - [ ] **Step 1: Write tests** ```java package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; import java.security.*; import java.security.spec.NamedParameterSpec; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class LicenseValidatorTest { private KeyPair generateKeyPair() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); return kpg.generateKeyPair(); } private String sign(PrivateKey key, String payload) throws Exception { Signature signer = Signature.getInstance("Ed25519"); signer.initSign(key); signer.update(payload.getBytes()); return Base64.getEncoder().encodeToString(signer.sign()); } @Test void validate_validLicense_returnsLicenseInfo() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64); Instant expires = Instant.now().plus(365, ChronoUnit.DAYS); String payload = """ {"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; LicenseInfo info = validator.validate(token); assertThat(info.tier()).isEqualTo("HIGH"); assertThat(info.hasFeature(Feature.debugger)).isTrue(); assertThat(info.hasFeature(Feature.replay)).isFalse(); assertThat(info.getLimit("max_agents", 0)).isEqualTo(50); assertThat(info.isExpired()).isFalse(); } @Test void validate_expiredLicense_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64); Instant past = Instant.now().minus(1, ChronoUnit.DAYS); String payload = """ {"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d} """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; assertThatThrownBy(() -> validator.validate(token)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("expired"); } @Test void validate_tamperedPayload_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); LicenseValidator validator = new LicenseValidator(publicKeyBase64); String payload = """ {"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999} """.trim(); String signature = sign(kp.getPrivate(), payload); // Tamper with payload String tampered = payload.replace("LOW", "BUSINESS"); String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature; assertThatThrownBy(() -> validator.validate(token)) .isInstanceOf(SecurityException.class) .hasMessageContaining("signature"); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false` Expected: Compilation error — LicenseValidator does not exist. - [ ] **Step 3: Implement LicenseValidator** ```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.*; import java.util.stream.Collectors; import java.util.stream.StreamSupport; public class LicenseValidator { private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class); private static final ObjectMapper objectMapper = new ObjectMapper(); private final PublicKey publicKey; public LicenseValidator(String publicKeyBase64) { 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); } } 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]); // Verify signature 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); } // Parse payload try { JsonNode root = objectMapper.readTree(payloadBytes); String tier = root.get("tier").asText(); Set features = new HashSet<>(); if (root.has("features")) { for (JsonNode f : root.get("features")) { try { features.add(Feature.valueOf(f.asText())); } catch (IllegalArgumentException e) { log.warn("Unknown feature in license: {}", f.asText()); } } } 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(); Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null; LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt); if (info.isExpired()) { throw new IllegalArgumentException("License expired at " + expiresAt); } return info; } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("Failed to parse license payload", e); } } } ``` - [ ] **Step 4: Run tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest` Expected: All 3 tests PASS. - [ ] **Step 5: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java git commit -m "feat: implement LicenseValidator with Ed25519 signature verification" ``` --- ### Task 3: LicenseGate — Feature Check Service **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java` - Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java` - [ ] **Step 1: Write tests** ```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 java.util.Set; import static org.assertj.core.api.Assertions.assertThat; class LicenseGateTest { @Test void noLicense_allFeaturesEnabled() { LicenseGate gate = new LicenseGate(); // No license loaded → open mode assertThat(gate.isEnabled(Feature.debugger)).isTrue(); assertThat(gate.isEnabled(Feature.replay)).isTrue(); assertThat(gate.isEnabled(Feature.lineage)).isTrue(); assertThat(gate.getTier()).isEqualTo("open"); } @Test void withLicense_onlyLicensedFeaturesEnabled() { LicenseGate gate = new LicenseGate(); LicenseInfo license = new LicenseInfo("MID", Set.of(Feature.topology, Feature.lineage, Feature.correlation), Map.of("max_agents", 10, "retention_days", 30), Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS)); gate.load(license); assertThat(gate.isEnabled(Feature.topology)).isTrue(); assertThat(gate.isEnabled(Feature.lineage)).isTrue(); assertThat(gate.isEnabled(Feature.debugger)).isFalse(); assertThat(gate.isEnabled(Feature.replay)).isFalse(); assertThat(gate.getTier()).isEqualTo("MID"); assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10); } } ``` - [ ] **Step 2: Implement LicenseGate** ```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={}, features={}, expires={}", license.tier(), license.features(), license.expiresAt()); } public boolean isEnabled(Feature feature) { return current.get().hasFeature(feature); } 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 3: Run tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseGateTest` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java git commit -m "feat: implement LicenseGate for feature checking" ``` --- ### Task 4: License Loading — Bean Config and Startup **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java` - Modify: `cameleer-server-app/src/main/resources/application.yml` - [ ] **Step 1: Add license config properties to application.yml** ```yaml license: token: ${CAMELEER_LICENSE_TOKEN:} file: ${CAMELEER_LICENSE_FILE:} public-key: ${CAMELEER_LICENSE_PUBLIC_KEY:} ``` - [ ] **Step 2: Implement LicenseBeanConfig** ```java package com.cameleer.server.app.config; 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.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.nio.file.Files; import java.nio.file.Path; @Configuration public class LicenseBeanConfig { private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class); @Value("${license.token:}") private String licenseToken; @Value("${license.file:}") private String licenseFile; @Value("${license.public-key:}") private String licensePublicKey; @Bean public LicenseGate licenseGate() { LicenseGate gate = new LicenseGate(); String token = resolveLicenseToken(); if (token == null || token.isBlank()) { log.info("No license configured — running in open mode (all features enabled)"); return gate; } if (licensePublicKey == null || licensePublicKey.isBlank()) { log.warn("License token provided but no public key configured (CAMELEER_LICENSE_PUBLIC_KEY). Running in open mode."); return gate; } try { LicenseValidator validator = new LicenseValidator(licensePublicKey); LicenseInfo info = validator.validate(token); gate.load(info); } catch (Exception e) { log.error("Failed to validate license: {}. Running in open mode.", e.getMessage()); } return gate; } private String resolveLicenseToken() { if (licenseToken != null && !licenseToken.isBlank()) { return licenseToken; } if (licenseFile != null && !licenseFile.isBlank()) { try { return Files.readString(Path.of(licenseFile)).trim(); } catch (Exception e) { log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage()); } } return null; } } ``` - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java git add cameleer-server-app/src/main/resources/application.yml git commit -m "feat: add license loading at startup from env var or file" ``` --- ### Task 5: License Admin API — Runtime License Update **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java` - [ ] **Step 1: Implement controller** ```java package com.cameleer.server.app.controller; import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.server.core.license.LicenseValidator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/v1/admin/license") @PreAuthorize("hasRole('ADMIN')") @Tag(name = "License Admin", description = "License management") public class LicenseAdminController { private final LicenseGate licenseGate; private final String licensePublicKey; public LicenseAdminController(LicenseGate licenseGate, @Value("${license.public-key:}") String licensePublicKey) { this.licenseGate = licenseGate; this.licensePublicKey = licensePublicKey; } @GetMapping @Operation(summary = "Get current license info") public ResponseEntity getCurrent() { return ResponseEntity.ok(licenseGate.getCurrent()); } record UpdateLicenseRequest(String token) {} @PostMapping @Operation(summary = "Update license token at runtime") public ResponseEntity update(@RequestBody UpdateLicenseRequest request) { if (licensePublicKey == null || licensePublicKey.isBlank()) { return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured")); } try { LicenseValidator validator = new LicenseValidator(licensePublicKey); LicenseInfo info = validator.validate(request.token()); licenseGate.load(info); return ResponseEntity.ok(info); } catch (Exception e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } } ``` - [ ] **Step 2: Run full test suite** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java git commit -m "feat: add license admin API for runtime license updates" ``` --- ### Task 6: Feature Gating — Wire LicenseGate Into Endpoints This task is a placeholder — MOAT feature endpoints don't exist yet. When they're added (debugger, lineage, correlation), they should inject `LicenseGate` and check `isEnabled(Feature.xxx)` before serving: ```java @GetMapping("/api/v1/debug/sessions") public ResponseEntity listDebugSessions() { if (!licenseGate.isEnabled(Feature.debugger)) { return ResponseEntity.status(403).body(Map.of("error", "Feature 'debugger' requires a HIGH or BUSINESS tier license")); } // ... serve debug sessions } ``` - [ ] **Step 1: No code changes needed now — document the pattern for MOAT feature implementation** - [ ] **Step 2: Final verification** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify` Expected: All tests PASS.