# 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 ``: ```xml com.cameleer cameleer-license-minter 1.0-SNAPSHOT ``` 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 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 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 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), 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) `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": ""}` - [ ] **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= CAMELEER_SERVER_LICENSE_PUBLICKEY= CAMELEER_SERVER_LICENSE_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