# License Minter Integration — 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:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.

**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.

**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.

**Decisions:**
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
- Private key stored in DB (signing_keys table)
- Features concept dropped — server enforces caps, not feature flags
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
- Verify tool: paste token → decode + validate signature → show envelope + state

---

## Phase 1: Backend Foundation

### Task 1: Maven dependency + Flyway migration

**Files:**
- Modify: `pom.xml`
- Create: `src/main/resources/db/migration/V002__license_minter.sql`

- [ ] **Step 1: Add minter dependency to pom.xml**

Add inside `<dependencies>`:
```xml
<!-- License Minter (Ed25519 signing) -->
<dependency>
    <groupId>com.cameleer</groupId>
    <artifactId>cameleer-license-minter</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
```

This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).

- [ ] **Step 2: Create Flyway V002 migration**

```sql
-- V002: License minter integration
-- Signing keys for Ed25519 license minting
CREATE TABLE signing_keys (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    public_key_b64 TEXT NOT NULL,
    private_key_b64 TEXT NOT NULL,
    active         BOOLEAN NOT NULL DEFAULT TRUE,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
-- Use a single pass via CASE to avoid this:
-- Actually, redo with CASE statement in a single UPDATE:

-- (Replace the 4 UPDATEs above with this single safe statement)
UPDATE tenants SET tier = CASE tier
    WHEN 'LOW' THEN 'STARTER'
    WHEN 'MID' THEN 'TEAM'
    WHEN 'HIGH' THEN 'BUSINESS'
    WHEN 'BUSINESS' THEN 'ENTERPRISE'
    ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');

-- Same for licenses table
UPDATE licenses SET tier = CASE tier
    WHEN 'LOW' THEN 'STARTER'
    WHEN 'MID' THEN 'TEAM'
    WHEN 'HIGH' THEN 'BUSINESS'
    WHEN 'BUSINESS' THEN 'ENTERPRISE'
    ELSE tier
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');

-- Add new license columns
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;

-- Drop features column (server enforces caps, not feature flags)
ALTER TABLE licenses DROP COLUMN features;
```

- [ ] **Step 3: Verify build compiles**

Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)

- [ ] **Step 4: Commit**

```
feat: add cameleer-license-minter dependency and V002 migration

Adds Ed25519 license minting library, signing_keys table,
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
adds label + grace_period_days to licenses, drops features column.
```

### Task 2: Tier enum rename + LicenseDefaults rewrite

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`

- [ ] **Step 1: Update Tier enum**

```java
package net.siegeln.cameleer.saas.tenant;

public enum Tier {
    STARTER, TEAM, BUSINESS, ENTERPRISE
}
```

- [ ] **Step 2: Update TenantEntity default**

Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`

- [ ] **Step 3: Update TenantService fallback**

Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`

- [ ] **Step 4: Rewrite LicenseDefaults**

Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.

```java
package net.siegeln.cameleer.saas.license;

import net.siegeln.cameleer.saas.tenant.Tier;
import java.util.Map;

public final class LicenseDefaults {

    private LicenseDefaults() {}

    public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
    public static final int DEFAULT_LICENSE_DAYS = 365;

    public static Map<String, Integer> limitsForTier(Tier tier) {
        return switch (tier) {
            case STARTER -> Map.ofEntries(
                Map.entry("max_environments", 2),
                Map.entry("max_apps", 10),
                Map.entry("max_agents", 20),
                Map.entry("max_users", 5),
                Map.entry("max_outbound_connections", 5),
                Map.entry("max_alert_rules", 10),
                Map.entry("max_total_cpu_millis", 8000),
                Map.entry("max_total_memory_mb", 8192),
                Map.entry("max_total_replicas", 25),
                Map.entry("max_execution_retention_days", 7),
                Map.entry("max_log_retention_days", 7),
                Map.entry("max_metric_retention_days", 7),
                Map.entry("max_jar_retention_count", 5)
            );
            case TEAM -> Map.ofEntries(
                Map.entry("max_environments", 5),
                Map.entry("max_apps", 50),
                Map.entry("max_agents", 100),
                Map.entry("max_users", 25),
                Map.entry("max_outbound_connections", 25),
                Map.entry("max_alert_rules", 50),
                Map.entry("max_total_cpu_millis", 32000),
                Map.entry("max_total_memory_mb", 32768),
                Map.entry("max_total_replicas", 100),
                Map.entry("max_execution_retention_days", 30),
                Map.entry("max_log_retention_days", 30),
                Map.entry("max_metric_retention_days", 30),
                Map.entry("max_jar_retention_count", 10)
            );
            case BUSINESS -> Map.ofEntries(
                Map.entry("max_environments", 10),
                Map.entry("max_apps", 200),
                Map.entry("max_agents", 500),
                Map.entry("max_users", 100),
                Map.entry("max_outbound_connections", 100),
                Map.entry("max_alert_rules", 200),
                Map.entry("max_total_cpu_millis", 128000),
                Map.entry("max_total_memory_mb", 131072),
                Map.entry("max_total_replicas", 500),
                Map.entry("max_execution_retention_days", 90),
                Map.entry("max_log_retention_days", 90),
                Map.entry("max_metric_retention_days", 90),
                Map.entry("max_jar_retention_count", 25)
            );
            case ENTERPRISE -> Map.ofEntries(
                Map.entry("max_environments", 50),
                Map.entry("max_apps", 1000),
                Map.entry("max_agents", 5000),
                Map.entry("max_users", 1000),
                Map.entry("max_outbound_connections", 500),
                Map.entry("max_alert_rules", 1000),
                Map.entry("max_total_cpu_millis", 512000),
                Map.entry("max_total_memory_mb", 524288),
                Map.entry("max_total_replicas", 2000),
                Map.entry("max_execution_retention_days", 365),
                Map.entry("max_log_retention_days", 180),
                Map.entry("max_metric_retention_days", 180),
                Map.entry("max_jar_retention_count", 50)
            );
        };
    }
}
```

- [ ] **Step 5: Commit**

```
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
```

### Task 3: SigningKeyService + SigningKeyEntity

**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`

- [ ] **Step 1: Create SigningKeyEntity**

JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).

- [ ] **Step 2: Create SigningKeyRepository**

JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.

- [ ] **Step 3: Create SigningKeyService**

Methods:
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
- `getPublicKeyBase64()` → convenience for the active key's public key
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64

Key generation:
```java
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
```

Private key reconstruction:
```java
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
```

- [ ] **Step 4: Commit**

```
feat: add SigningKeyService for Ed25519 keypair management
```

### Task 4: Rewrite LicenseService + LicenseEntity

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`

- [ ] **Step 1: Update LicenseEntity**

- Remove `features` field + getter/setter
- Add `label` (String) field + getter/setter
- Add `gracePeriodDays` (int) field + getter/setter

- [ ] **Step 2: Rewrite LicenseService**

- Add `SigningKeyService` dependency
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
  - Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
  - Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
  - Store signed token in entity
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
- Remove `verifyLicenseToken()` (server validates cryptographically)
- Add `verifyToken(String token)` that uses `LicenseValidator`

- [ ] **Step 3: Update LicenseResponse DTO**

Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.

- [ ] **Step 4: Commit**

```
feat: rewrite LicenseService to mint Ed25519-signed tokens
```

### Task 5: Update controllers + portal service

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`

- [ ] **Step 1: Update VendorTenantController**

- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
- Add `GET /license-presets` endpoint returning tier presets
- Add `POST /license/verify` endpoint
- Add `GET /signing-key/public` endpoint

- [ ] **Step 2: Update VendorTenantService**

- `renewLicense()` updated to accept customizable parameters
- Add `mintLicense()` method with full limit configuration
- Add `verifyToken()` delegation

- [ ] **Step 3: Update VendorTenantController response types**

- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
- `VendorTenantDetail` — license field uses updated LicenseResponse

- [ ] **Step 4: Update TenantPortalService**

- `DashboardData` — drop features, keep limits
- `LicenseData` — drop features, add label + gracePeriodDays

- [ ] **Step 5: Commit**

```
feat: update vendor/portal APIs for Ed25519 license minting
```

### Task 6: Fix tests

**Files:**
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`

- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**

- [ ] **Step 2: Update LicenseServiceTest**

- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
- Remove feature-related assertions
- Mock `SigningKeyService` to return a test keypair
- Remove `verifyLicenseToken` tests

- [ ] **Step 3: Update LicenseControllerTest**

- Remove feature assertions (`features.correlation`)
- Update tier values in assertions

- [ ] **Step 4: Run tests**

Run: `mvn test -q`

- [ ] **Step 5: Commit**

```
test: update tests for Ed25519 license minting and tier rename
```

## Phase 2: Provisioning Integration

### Task 7: Push public key to tenant containers

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`

- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**

Add `SigningKeyService` as a constructor dependency.

- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**

In `createServerContainer()`, after the existing env vars, add:
```java
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
```

`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).

- [ ] **Step 3: Commit**

```
feat: push Ed25519 public key to tenant server containers
```

## Phase 3: Vendor API — Configurable Minting

### Task 8: Vendor license endpoints

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`

- [ ] **Step 1: Create DTOs**

`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)

`VerifyLicenseRequest`: token (String)

`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)

`LicensePreset`: tier (String), limits (Map<String,Integer>)

`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)

- [ ] **Step 2: Update VendorTenantService**

Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
- Resolves limits from request (or tier preset)
- Calls `licenseService.generateLicense()` with full params
- Optionally pushes to server
- Returns the license + public key + slug for the bundle

Add `verifyToken(String token)`:
- Uses LicenseValidator from server-core

- [ ] **Step 3: Update VendorTenantController**

- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
- `GET /license-presets` — returns list of LicensePreset
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`

- [ ] **Step 4: Commit**

```
feat: add vendor license minting, presets, and verify endpoints
```

## Phase 4: Vendor UI — License Minting

### Task 9: Update frontend types + hooks

**Files:**
- Modify: `ui/src/types/api.ts`
- Modify: `ui/src/api/vendor-hooks.ts`

- [ ] **Step 1: Update types**

- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
- `DashboardData` — remove `features`
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`

- [ ] **Step 2: Update hooks**

- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
- Add `useLicensePresets()`
- Add `useVerifyLicense()`
- Add `usePublicKey()`

- [ ] **Step 3: Commit**

```
feat(ui): update types and hooks for Ed25519 license minting
```

### Task 10: License minting form on TenantDetailPage

**Files:**
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`

- [ ] **Step 1: Replace License card**

Replace the simple "Renew License" button with a minting form:
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
- All 13 limits editable in a grid
- Expiry date picker, grace period input, label input
- "Custom" indicator when limits diverge from preset
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"

- [ ] **Step 2: License bundle display**

After minting, show a dialog/card with the full env-var bundle:
```
CAMELEER_SERVER_TENANT_ID=<slug>
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
CAMELEER_SERVER_LICENSE_TOKEN=<token>
```
With a "Copy Bundle" button.

- [ ] **Step 3: Commit**

```
feat(ui): add license minting form with tier presets and bundle distribution
```

### Task 11: License verify tool + public key viewer

**Files:**
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
- Modify: `ui/src/router.tsx` (add route)
- Modify: `ui/src/Layout.tsx` (add nav item)

- [ ] **Step 1: Create LicenseVerifyPage**

- Textarea to paste a token
- "Verify" button
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
- Public key display section with copy button

- [ ] **Step 2: Add route and navigation**

Route: `/vendor/license-verify`
Nav: "License Tools" section in vendor sidebar

- [ ] **Step 3: Commit**

```
feat(ui): add license verify tool and public key viewer
```

### Task 12: Update tier color utility

**Files:**
- Modify: `ui/src/utils/tier.ts`

- [ ] **Step 1: Update tierColor**

```typescript
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
  switch (tier?.toUpperCase()) {
    case 'ENTERPRISE': return 'success';
    case 'BUSINESS': return 'primary';
    case 'TEAM': return 'running';
    case 'STARTER': return 'warning';
    default: return 'auto';
  }
}
```

- [ ] **Step 2: Commit**

```
fix(ui): update tier color mapping for renamed tiers
```

## Phase 5: Tenant UI Updates

### Task 13: Update TenantLicensePage

**Files:**
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`

- [ ] **Step 1: Remove features card, update limits card**

- Drop the "Features" card entirely
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
- Show grace period and label if present

- [ ] **Step 2: Commit**

```
feat(ui): update tenant license page for Ed25519 model
```

### Task 14: Update TenantDashboardPage

**Files:**
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`

- [ ] **Step 1: Remove features references**

Drop any `features` display. Keep limits display.

- [ ] **Step 2: Commit**

```
fix(ui): remove features from tenant dashboard
```

### Task 15: Update CreateTenantPage

**Files:**
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`

- [ ] **Step 1: Update tier options**

Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.

- [ ] **Step 2: Commit**

```
fix(ui): update tier options in create tenant form
```

---

## Verification

After all tasks:
- [ ] `mvn test` passes
- [ ] `cd ui && npm run build` succeeds
- [ ] Docker compose boots (if available)
- [ ] Verify a tenant can be created with STARTER tier
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env
