4084 lines
147 KiB
Markdown
4084 lines
147 KiB
Markdown
|
|
# 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) <noreply@anthropic.com>`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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<String, Integer> 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<LicenseInfo> 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<Feature> 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) <noreply@anthropic.com>
|
|||
|
|
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<String, Integer> 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) <noreply@anthropic.com>
|
|||
|
|
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<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();
|
|||
|
|
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) <noreply@anthropic.com>
|
|||
|
|
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<String, Integer> DEFAULTS;
|
|||
|
|
|
|||
|
|
static {
|
|||
|
|
Map<String, Integer> 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<String, Integer> values) {
|
|||
|
|
|
|||
|
|
public LicenseLimits {
|
|||
|
|
Objects.requireNonNull(values, "values");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static LicenseLimits defaultsOnly() {
|
|||
|
|
return new LicenseLimits(DefaultTierLimits.DEFAULTS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
|
|||
|
|
java.util.Map<String, Integer> 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) <noreply@anthropic.com>
|
|||
|
|
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<Snapshot> 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) <noreply@anthropic.com>
|
|||
|
|
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
|
|||
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|||
|
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|||
|
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|||
|
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|||
|
|
<modelVersion>4.0.0</modelVersion>
|
|||
|
|
|
|||
|
|
<parent>
|
|||
|
|
<groupId>com.cameleer</groupId>
|
|||
|
|
<artifactId>cameleer-server-parent</artifactId>
|
|||
|
|
<version>1.0-SNAPSHOT</version>
|
|||
|
|
</parent>
|
|||
|
|
|
|||
|
|
<artifactId>cameleer-license-minter</artifactId>
|
|||
|
|
<name>Cameleer License Minter</name>
|
|||
|
|
<description>Vendor-only Ed25519 license signing library + CLI</description>
|
|||
|
|
|
|||
|
|
<dependencies>
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>com.cameleer</groupId>
|
|||
|
|
<artifactId>cameleer-server-core</artifactId>
|
|||
|
|
</dependency>
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>com.fasterxml.jackson.core</groupId>
|
|||
|
|
<artifactId>jackson-databind</artifactId>
|
|||
|
|
</dependency>
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>org.slf4j</groupId>
|
|||
|
|
<artifactId>slf4j-api</artifactId>
|
|||
|
|
</dependency>
|
|||
|
|
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>org.junit.jupiter</groupId>
|
|||
|
|
<artifactId>junit-jupiter</artifactId>
|
|||
|
|
<scope>test</scope>
|
|||
|
|
</dependency>
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>org.assertj</groupId>
|
|||
|
|
<artifactId>assertj-core</artifactId>
|
|||
|
|
<scope>test</scope>
|
|||
|
|
</dependency>
|
|||
|
|
</dependencies>
|
|||
|
|
|
|||
|
|
<build>
|
|||
|
|
<plugins>
|
|||
|
|
<plugin>
|
|||
|
|
<groupId>org.springframework.boot</groupId>
|
|||
|
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
|||
|
|
<executions>
|
|||
|
|
<execution>
|
|||
|
|
<id>repackage-cli</id>
|
|||
|
|
<goals>
|
|||
|
|
<goal>repackage</goal>
|
|||
|
|
</goals>
|
|||
|
|
<configuration>
|
|||
|
|
<classifier>cli</classifier>
|
|||
|
|
<mainClass>com.cameleer.license.minter.cli.LicenseMinterCli</mainClass>
|
|||
|
|
</configuration>
|
|||
|
|
</execution>
|
|||
|
|
</executions>
|
|||
|
|
</plugin>
|
|||
|
|
</plugins>
|
|||
|
|
</build>
|
|||
|
|
</project>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6.3: Register the module in root `pom.xml`**
|
|||
|
|
|
|||
|
|
Edit `pom.xml`, locate the `<modules>` block, add the new entry:
|
|||
|
|
|
|||
|
|
```xml
|
|||
|
|
<modules>
|
|||
|
|
<module>cameleer-server-core</module>
|
|||
|
|
<module>cameleer-server-app</module>
|
|||
|
|
<module>cameleer-license-minter</module>
|
|||
|
|
</modules>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **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) <noreply@anthropic.com>
|
|||
|
|
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.<String, Integer>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<String, Integer> 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<String, Integer> 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) <noreply@anthropic.com>
|
|||
|
|
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<String> 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<String, String> flags = new LinkedHashMap<>();
|
|||
|
|
Set<String> bool = new HashSet<>();
|
|||
|
|
Map<String, Integer> 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) <noreply@anthropic.com>
|
|||
|
|
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) <noreply@anthropic.com>
|
|||
|
|
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) <noreply@anthropic.com>
|
|||
|
|
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<LicenseRecord> 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<LicenseRecord> 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<LicenseRecord> 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) <noreply@anthropic.com>
|
|||
|
|
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) <noreply@anthropic.com>
|
|||
|
|
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<String, Object> 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<LicenseRecord> 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<String, Object> 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<String> envToken, Optional<String> 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<LicenseRecord> 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<LicenseRecord> 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<String, Object> 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) <noreply@anthropic.com>
|
|||
|
|
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<String> env = (envToken != null && !envToken.isBlank())
|
|||
|
|
? Optional.of(envToken) : Optional.empty();
|
|||
|
|
Optional<String> 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) <noreply@anthropic.com>
|
|||
|
|
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<Map<String, Object>> handle(LicenseCapExceededException e) {
|
|||
|
|
var state = gate.getState();
|
|||
|
|
LicenseInfo info = gate.getCurrent();
|
|||
|
|
String reason = gate.getInvalidReason();
|
|||
|
|
Map<String, Object> 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) <noreply@anthropic.com>
|
|||
|
|
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<String, Integer> 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<String, Counter> 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) <noreply@anthropic.com>
|
|||
|
|
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<String, Long> snapshot() {
|
|||
|
|
Map<String, Long> 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<String, Long> 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) <noreply@anthropic.com>
|
|||
|
|
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) <noreply@anthropic.com>
|
|||
|
|
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) <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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<String, Object> 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<String, String> 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<Map<String, Object>> getCurrent() {
|
|||
|
|
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> limitRows = new java.util.ArrayList<>();
|
|||
|
|
for (var key : effective.values().keySet()) {
|
|||
|
|
Map<String, Object> 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<String, Object> 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<LicenseState, AtomicReference<Double>> stateGauges = new EnumMap<>(LicenseState.class);
|
|||
|
|
private final AtomicReference<Double> daysRemaining = new AtomicReference<>(0.0);
|
|||
|
|
private final AtomicReference<Double> 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<String, AtomicReference<Double>>` 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 `<scope>test</scope>` 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) <noreply@anthropic.com>
|
|||
|
|
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).
|
|||
|
|
|
|||
|
|
|