From 13bd03997ae3d100c3cfcc78b4c1239f3b90a3af Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:20:22 +0200 Subject: [PATCH] refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../2026-04-26-license-minter-integration.md | 614 ++++++++++++++++++ .../saas/license/LicenseController.java | 11 +- .../saas/license/LicenseDefaults.java | 92 ++- .../cameleer/saas/license/LicenseEntity.java | 14 +- .../cameleer/saas/license/LicenseService.java | 6 +- .../saas/license/dto/LicenseResponse.java | 5 +- .../saas/portal/TenantPortalService.java | 15 +- .../cameleer/saas/tenant/TenantEntity.java | 2 +- .../cameleer/saas/tenant/TenantService.java | 2 +- .../siegeln/cameleer/saas/tenant/Tier.java | 2 +- .../saas/vendor/VendorTenantController.java | 4 +- 11 files changed, 704 insertions(+), 63 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-26-license-minter-integration.md diff --git a/docs/superpowers/plans/2026-04-26-license-minter-integration.md b/docs/superpowers/plans/2026-04-26-license-minter-integration.md new file mode 100644 index 0000000..7052873 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-license-minter-integration.md @@ -0,0 +1,614 @@ +# 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 diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java index 7ba1b71..68e51fa 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java @@ -55,15 +55,6 @@ public class LicenseController { } private LicenseResponse toResponse(LicenseEntity entity) { - return new LicenseResponse( - entity.getId(), - entity.getTenantId(), - entity.getTier(), - entity.getFeatures(), - entity.getLimits(), - entity.getIssuedAt(), - entity.getExpiresAt(), - entity.getToken() - ); + return LicenseResponse.from(entity); } } diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java index 733d85a..43ace93 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java @@ -8,37 +8,71 @@ public final class LicenseDefaults { private LicenseDefaults() {} - public static Map featuresForTier(Tier tier) { - return switch (tier) { - case LOW -> Map.of( - "topology", true, "lineage", false, - "correlation", false, "debugger", false, "replay", false); - case MID -> Map.of( - "topology", true, "lineage", true, - "correlation", true, "debugger", false, "replay", false); - case HIGH -> Map.of( - "topology", true, "lineage", true, - "correlation", true, "debugger", true, "replay", true); - case BUSINESS -> Map.of( - "topology", true, "lineage", true, - "correlation", true, "debugger", true, "replay", true); - }; - } + public static final int DEFAULT_GRACE_PERIOD_DAYS = 14; + public static final int DEFAULT_LICENSE_DAYS = 365; - public static Map limitsForTier(Tier tier) { + public static Map limitsForTier(Tier tier) { return switch (tier) { - case LOW -> Map.of( - "max_agents", 3, "retention_days", 7, - "max_environments", 1); - case MID -> Map.of( - "max_agents", 10, "retention_days", 30, - "max_environments", 2); - case HIGH -> Map.of( - "max_agents", 50, "retention_days", 90, - "max_environments", -1); - case BUSINESS -> Map.of( - "max_agents", -1, "retention_days", 365, - "max_environments", -1); + 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) + ); }; } } diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java index d0a1639..dc436f5 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java @@ -28,14 +28,16 @@ public class LicenseEntity { @Column(name = "tier", nullable = false, length = 20) private String tier; - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "features", nullable = false, columnDefinition = "jsonb") - private Map features; + @Column(name = "label") + private String label; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "limits", nullable = false, columnDefinition = "jsonb") private Map limits; + @Column(name = "grace_period_days", nullable = false) + private int gracePeriodDays; + @Column(name = "issued_at", nullable = false) private Instant issuedAt; @@ -62,10 +64,12 @@ public class LicenseEntity { public void setTenantId(UUID tenantId) { this.tenantId = tenantId; } public String getTier() { return tier; } public void setTier(String tier) { this.tier = tier; } - public Map getFeatures() { return features; } - public void setFeatures(Map features) { this.features = features; } + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } public Map getLimits() { return limits; } public void setLimits(Map limits) { this.limits = limits; } + public int getGracePeriodDays() { return gracePeriodDays; } + public void setGracePeriodDays(int gracePeriodDays) { this.gracePeriodDays = gracePeriodDays; } public Instant getIssuedAt() { return issuedAt; } public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; } public Instant getExpiresAt() { return expiresAt; } diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java index 8bb32a8..5d22dd6 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java @@ -23,7 +23,6 @@ public class LicenseService { } public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) { - var features = LicenseDefaults.featuresForTier(tenant.getTier()); var limits = LicenseDefaults.limitsForTier(tenant.getTier()); Instant now = Instant.now(); Instant expiresAt = now.plus(validity); @@ -33,10 +32,10 @@ public class LicenseService { var entity = new LicenseEntity(); entity.setTenantId(tenant.getId()); entity.setTier(tenant.getTier().name()); - entity.setFeatures(features); - entity.setLimits(limits); + entity.setLimits(new java.util.HashMap<>(limits)); entity.setIssuedAt(now); entity.setExpiresAt(expiresAt); + entity.setGracePeriodDays(LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS); entity.setToken(token); var saved = licenseRepository.save(entity); @@ -75,7 +74,6 @@ public class LicenseService { .map(e -> Map.of( "tenant_id", e.getTenantId().toString(), "tier", e.getTier(), - "features", e.getFeatures(), "limits", e.getLimits() )); } diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java index 2dfa2eb..4381088 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java @@ -10,8 +10,9 @@ public record LicenseResponse( UUID id, UUID tenantId, String tier, - Map features, + String label, Map limits, + int gracePeriodDays, Instant issuedAt, Instant expiresAt, String token @@ -19,7 +20,7 @@ public record LicenseResponse( public static LicenseResponse from(LicenseEntity e) { return new LicenseResponse( e.getId(), e.getTenantId(), e.getTier(), - e.getFeatures(), e.getLimits(), + e.getLabel(), e.getLimits(), e.getGracePeriodDays(), e.getIssuedAt(), e.getExpiresAt(), e.getToken() ); diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index d31996f..fa90961 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -61,13 +61,14 @@ public class TenantPortalService { String name, String slug, String tier, String status, boolean serverHealthy, String serverStatus, String serverEndpoint, String licenseTier, long licenseDaysRemaining, - Map limits, Map features, + Map limits, int agentCount, int environmentCount ) {} public record LicenseData( - UUID id, String tier, Map features, Map limits, - Instant issuedAt, Instant expiresAt, String token, long daysRemaining + UUID id, String tier, String label, Map limits, + int gracePeriodDays, Instant issuedAt, Instant expiresAt, + String token, long daysRemaining ) {} public record TenantSettingsData( @@ -118,7 +119,6 @@ public class TenantPortalService { String licenseTier = null; long licenseDaysRemaining = 0; Map limits = Map.of(); - Map features = Map.of(); var licenseOpt = licenseService.getActiveLicense(tenant.getId()); if (licenseOpt.isPresent()) { @@ -126,7 +126,6 @@ public class TenantPortalService { licenseTier = lic.getTier(); licenseDaysRemaining = daysUntil(lic.getExpiresAt()); limits = lic.getLimits() != null ? lic.getLimits() : Map.of(); - features = lic.getFeatures() != null ? lic.getFeatures() : Map.of(); } return new DashboardData( @@ -134,7 +133,7 @@ public class TenantPortalService { tenant.getTier().name(), tenant.getStatus().name(), serverHealthy, serverStatus, endpoint, licenseTier, licenseDaysRemaining, - limits, features, agentCount, environmentCount + limits, agentCount, environmentCount ); } @@ -142,9 +141,9 @@ public class TenantPortalService { TenantEntity tenant = resolveTenant(); return licenseService.getActiveLicense(tenant.getId()) .map(lic -> new LicenseData( - lic.getId(), lic.getTier(), - lic.getFeatures() != null ? lic.getFeatures() : Map.of(), + lic.getId(), lic.getTier(), lic.getLabel(), lic.getLimits() != null ? lic.getLimits() : Map.of(), + lic.getGracePeriodDays(), lic.getIssuedAt(), lic.getExpiresAt(), lic.getToken(), daysUntil(lic.getExpiresAt()) )) diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java index aa18352..5bdb303 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java @@ -33,7 +33,7 @@ public class TenantEntity { @Enumerated(EnumType.STRING) @Column(name = "tier", nullable = false, length = 20) - private Tier tier = Tier.LOW; + private Tier tier = Tier.STARTER; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 20) diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java index e31440e..fa64425 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -31,7 +31,7 @@ public class TenantService { var entity = new TenantEntity(); entity.setName(request.name()); entity.setSlug(request.slug()); - entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW); + entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER); entity.setStatus(TenantStatus.PROVISIONING); var saved = tenantRepository.save(entity); diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java b/src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java index eb04075..6a31f34 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java @@ -1,5 +1,5 @@ package net.siegeln.cameleer.saas.tenant; public enum Tier { - LOW, MID, HIGH, BUSINESS + STARTER, TEAM, BUSINESS, ENTERPRISE } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java index 0e9d452..4634112 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java @@ -82,8 +82,8 @@ public class VendorTenantController { var license = vendorTenantService.getLicenseForTenant(tenant.getId()); if (license.isPresent() && license.get().getLimits() != null) { var limits = license.get().getLimits(); - if (limits.containsKey("agents")) { - agentLimit = ((Number) limits.get("agents")).intValue(); + if (limits.containsKey("max_agents")) { + agentLimit = ((Number) limits.get("max_agents")).intValue(); } } return new VendorTenantSummary(