616 lines
21 KiB
Markdown
616 lines
21 KiB
Markdown
# 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\cameleer3-server`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### New Files
|
|
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
|
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
|
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
|
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
|
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
|
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
|
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
|
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
|
|
|
### Modified Files
|
|
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
|
|
|
|
---
|
|
|
|
### Task 1: Core Domain — LicenseInfo, Feature Enum
|
|
|
|
**Files:**
|
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
|
|
|
- [ ] **Step 1: Create Feature enum**
|
|
|
|
```java
|
|
package com.cameleer3.server.core.license;
|
|
|
|
public enum Feature {
|
|
topology,
|
|
lineage,
|
|
correlation,
|
|
debugger,
|
|
replay
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create LicenseInfo record**
|
|
|
|
```java
|
|
package com.cameleer3.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**
|
|
|
|
```bash
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
|
|
git commit -m "feat: add LicenseInfo and Feature domain model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: LicenseValidator — Ed25519 JWT Verification
|
|
|
|
**Files:**
|
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
|
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
|
|
|
- [ ] **Step 1: Write tests**
|
|
|
|
```java
|
|
package com.cameleer3.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/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
|
Expected: Compilation error — LicenseValidator does not exist.
|
|
|
|
- [ ] **Step 3: Implement LicenseValidator**
|
|
|
|
```java
|
|
package com.cameleer3.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/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
|
|
Expected: All 3 tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
|
|
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
|
|
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: LicenseGate — Feature Check Service
|
|
|
|
**Files:**
|
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
|
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
|
|
|
- [ ] **Step 1: Write tests**
|
|
|
|
```java
|
|
package com.cameleer3.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.cameleer3.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/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
|
|
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
|
|
git commit -m "feat: implement LicenseGate for feature checking"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: License Loading — Bean Config and Startup
|
|
|
|
**Files:**
|
|
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
|
- Modify: `cameleer3-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.cameleer3.server.app.config;
|
|
|
|
import com.cameleer3.server.core.license.LicenseGate;
|
|
import com.cameleer3.server.core.license.LicenseInfo;
|
|
import com.cameleer3.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 cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
|
|
git add cameleer3-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: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
|
|
|
- [ ] **Step 1: Implement controller**
|
|
|
|
```java
|
|
package com.cameleer3.server.app.controller;
|
|
|
|
import com.cameleer3.server.core.license.LicenseGate;
|
|
import com.cameleer3.server.core.license.LicenseInfo;
|
|
import com.cameleer3.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/cameleer3-server && mvn clean verify`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/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/cameleer3-server && mvn clean verify`
|
|
Expected: All tests PASS.
|