- Tier enum: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE - LicenseDefaults: 13-key limits per tier matching server handoff cap matrix - Drop features concept from LicenseEntity, LicenseResponse, portal DTOs - Add label and gracePeriodDays to LicenseEntity - Fix agent limit key from 'agents' to 'max_agents' in VendorTenantController Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
21 KiB
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>:
<!-- 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
-- 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
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().
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 callgetPublicKeyBase64()→ convenience for the active key's public keygetPrivateKey()→ reconstructsPrivateKeyfrom stored base64
Key generation:
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:
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
featuresfield + getter/setter -
Add
label(String) field + getter/setter -
Add
gracePeriodDays(int) field + getter/setter -
Step 2: Rewrite LicenseService
-
Add
SigningKeyServicedependency -
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
- Build
-
Add convenience overload
generateLicense(TenantEntity, Duration, UUID actorId)that uses tier presets -
Remove
verifyLicenseToken()(server validates cryptographically) -
Add
verifyToken(String token)that usesLicenseValidator -
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}/licensenow takes a request body with limits, expiresAt, gracePeriodDays, label -
Add
GET /license-presetsendpoint returning tier presets -
Add
POST /license/verifyendpoint -
Add
GET /signing-key/publicendpoint -
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— fixagentLimitto usemax_agentskey -
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 togenerateLicense_producesSignedToken, assert token contains.separator -
Remove feature-related assertions
-
Mock
SigningKeyServiceto return a test keypair -
Remove
verifyLicenseTokentests -
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:
"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— removefeatures, addlabel,gracePeriodDays,publicKeyB64,tenantSlug -
Add
MintLicenseRequest,VerifyLicenseRequest,VerifyLicenseResponse,LicensePreset,LicenseBundleResponse -
DashboardData— removefeatures -
TenantLicenseData— removefeatures, addlabel,gracePeriodDays -
Step 2: Update hooks
-
useRenewLicense()→ replace withuseMintLicense(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
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 testpassescd ui && npm run buildsucceeds- 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