Files
cameleer-saas/docs/superpowers/plans/2026-04-26-license-minter-integration.md
hsiegeln 13bd03997a refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
- 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>
2026-04-26 17:20:22 +02:00

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 call
  • getPublicKeyBase64() → convenience for the active key's public key
  • getPrivateKey() → reconstructs PrivateKey from 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 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:

"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

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