Files
cameleer-saas/docs/superpowers/plans/2026-04-07-plan2-license-validation.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

21 KiB

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

package com.cameleer.server.core.license;

public enum Feature {
    topology,
    lineage,
    correlation,
    debugger,
    replay
}
  • Step 2: Create LicenseInfo record
package com.cameleer.server.core.license;

import java.time.Instant;
import java.util.Map;
import java.util.Set;

public record LicenseInfo(
        String tier,
        Set<Feature> features,
        Map<String, Integer> 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
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

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
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<Feature> 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<String, Integer> 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
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

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
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<LicenseInfo> 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
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

license:
  token: ${CAMELEER_LICENSE_TOKEN:}
  file: ${CAMELEER_LICENSE_FILE:}
  public-key: ${CAMELEER_LICENSE_PUBLIC_KEY:}
  • Step 2: Implement LicenseBeanConfig
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
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

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<LicenseInfo> 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
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:

@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.