**Scope:** Redesign the SaaS platform from a read-only tenant viewer into a functional vendor management plane with tenant provisioning, license management, and customer self-service.
## Context
The SaaS platform currently has 3 pages (Dashboard, License, Admin Tenants) — all read-only. It cannot create tenants, provision servers, manage licenses, or let customers configure their own settings. The backend has foundations (TenantService, LicenseService, LogtoManagementClient, ServerApiClient, audit logging) but none are exposed through management workflows.
This spec redesigns the platform around two personas — **vendor** (us) and **customer** (tenant admin) — with a clear separation of concerns.
### Architectural Decisions (from brainstorming)
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Server isolation | Shared data stores, isolated server per tenant | Server is already standalone; PostgreSQL/ClickHouse shared with tenant_id partitioning |
| Auth model | Hybrid — SaaS uses Logto, server uses customer OIDC | Clean separation: SaaS is vendor plane, server is product plane |
| Tenant admin access | Both SaaS + server, with SSO bridge | Admin configures in SaaS, jumps to server for operations |
| Server data in SaaS | License compliance + health summary | Quick pulse without duplicating the server dashboard |
| Provisioning mechanism | Docker API via docker-java | Already a dependency, same pattern as server's RuntimeOrchestrator |
| Docker/K8s support | Pluggable interface, Docker first | Mirror server's RuntimeOrchestrator + auto-detection pattern |
---
## 1. Personas & User Stories
### Vendor (platform:admin scope)
| ID | Story | Acceptance Criteria |
|----|-------|-------------------|
| V1 | As a vendor, I want to create a tenant so I can onboard a new customer | Form collects name, slug, tier. Creates DB record + Logto org. Status = PROVISIONING. |
| V2 | As a vendor, I want to provision a server for a tenant so they have a running Cameleer instance | After tenant creation, SaaS creates a cameleer-server container via Docker API with correct env vars, network, and Traefik labels. Health check passes → status = ACTIVE. |
| V3 | As a vendor, I want to generate and assign a license to a tenant | License created with tier-appropriate features/limits/expiry. Token pushed to tenant's server via M2M API. |
| V4 | As a vendor, I want to suspend a tenant who hasn't paid | Suspend stops the server container and marks tenant SUSPENDED. Reactivation restarts it. |
| V5 | As a vendor, I want to view fleet health at a glance | Tenant list shows each tenant's server status (running/stopped/error), agent count vs limit, license expiry. |
| V6 | As a vendor, I want to delete/offboard a tenant | Stops and removes server container, revokes license, marks tenant DELETED. |
### Customer (tenant admin, org-scoped JWT)
| ID | Story | Acceptance Criteria |
|----|-------|-------------------|
| C1 | As a tenant admin, I want to see my dashboard with server health and license usage | Dashboard shows: server status (up/down), connected agents vs limit, environments vs limit, feature entitlements. |
| C2 | As a tenant admin, I want to configure external OIDC for my team | Form to set issuer URI, client ID, client secret, audience, claim mappings. SaaS pushes config to the tenant's server via M2M API. |
| C3 | As a tenant admin, I want to manage team members | View/invite/remove users in Logto org. Assign roles (owner/operator/viewer) that flow through to server access. |
| C4 | As a tenant admin, I want to access the server dashboard seamlessly | "Open Server Dashboard" navigates to the tenant's server URL. Initial auth via Logto (same OIDC provider until customer configures their own). |
| C5 | As a tenant admin, I want to view my license details | Tier, features, limits, validity, days remaining — enriched with actual usage data from server. |
| C6 | As a tenant admin, I want to see my organization settings | Tenant name, slug, tier, created date. Read-only (tier changes go through vendor). |
---
## 2. Information Architecture
### Route Structure
```
/platform/
├── /vendor/ (platform:admin only)
│ ├── /vendor/tenants Tenant list with fleet health overview
│ └── /vendor/tenants/:id Tenant detail — server status, license, actions
│
├── /tenant/ (org-scoped, any authenticated user)
│ ├── /tenant/ Dashboard — server health + license usage
│ ├── /tenant/license License details + usage vs limits
│ ├── /tenant/oidc External OIDC configuration
│ ├── /tenant/team Team members + role management
│ └── /tenant/settings Organization settings
│
├── /login Logto OIDC redirect
└── /callback Logto callback handler
```
### Navigation
**Sidebar adapts to persona:**
- **Vendor** (`platform:admin`): "Tenants" section at top. If a tenant is selected (e.g., viewing detail), the tenant portal sections appear below for support/debugging.
- **Customer** (no `platform:admin`): Dashboard, License, OIDC, Team, Settings.
- **Footer**: "Open Server Dashboard" (contextual to current tenant).
**Landing page:**
-`platform:admin` → `/vendor/tenants`
- Otherwise → `/tenant/`
### What Happens to Existing Pages
| Current | Becomes | Changes |
|---------|---------|---------|
| `DashboardPage` | `/tenant/` | Add health data from server, license usage indicators |
| Traefik | `PathPrefix(/t/${slug})` with `priority=2` (higher than API) |
The server UI serves static assets and proxies API calls to the backend. The `BASE_PATH` env var configures React Router's basename and nginx proxy target.
### Provision Flow
```
Vendor clicks "Create Tenant"
→ POST /api/vendor/tenants
1. Validate slug uniqueness
2. Create TenantEntity (status=PROVISIONING)
3. Create Logto organization
4. Generate license (tier-appropriate, 365 days)
5. Create server container (DockerTenantProvisioner.provision())
6. Create server UI container
7. Wait for health check (poll /actuator/health, timeout 60s)
8. Push license to server via M2M API (ServerApiClient)
**ServerUsage** (from server API — new endpoint or existing data):
- Agent count vs license limit
- Environment count vs license limit
- Which features are actively used (topology, lineage, etc.)
The SaaS caches health data per tenant (refresh every 30s for the fleet view, on-demand for detail pages).
### SSO Bridge
**Initial state** (before customer OIDC): The tenant's server trusts Logto. The tenant admin has a Logto account. "Open Server Dashboard" navigates to `/t/{slug}/` — the server's OIDC flow detects the existing Logto session and authenticates the user.
**After customer OIDC**: The SaaS pushes the customer's OIDC config to the server via `ServerApiClient.pushOidcConfig()`. The server switches to trusting the customer's provider. The tenant admin authenticates via their company's OIDC when accessing the server.
---
## 5. Backend API Design
### Vendor Endpoints (platform:admin required)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/vendor/tenants` | List all tenants with health summary |
| `GET` | `/api/tenant/team` | Team members (from Logto org) |
| `POST` | `/api/tenant/team/invite` | Invite member |
| `PATCH` | `/api/tenant/team/{userId}/role` | Change member role |
| `DELETE` | `/api/tenant/team/{userId}` | Remove member |
| `GET` | `/api/tenant/settings` | Org settings |
### Existing Endpoints to Modify
| Current | Change |
|---------|--------|
| `GET /api/tenants` | Move to `/api/vendor/tenants`, add health data |
| `POST /api/tenants` | Move to `/api/vendor/tenants`, add provisioning |
| `GET /api/tenants/{id}` | Keep for backward compat, also available at `/api/vendor/tenants/{id}` |
| `GET /api/tenants/{id}/license` | Keep, also available at `/api/tenant/license` |
| `POST /api/tenants/{id}/license` | Move to `/api/vendor/tenants/{id}/license` |
| `GET /api/me` | Keep (used by OrgResolver) |
| `GET /api/config` | Keep (used by frontend bootstrap) |
---
## 6. Frontend Design
### Vendor Console
**Tenant List** (`/vendor/tenants`):
- DataTable with columns: Name, Slug, Tier (Badge), Status (Badge), Server (health indicator), Agents (used/limit), License (expiry or "None"), Created
- Row click → tenant detail
- "+ Create Tenant" button in header
- Status badges: ACTIVE (green), PROVISIONING (blue), SUSPENDED (amber), DELETED (gray)
- Server health: green dot (UP), red dot (DOWN), gray dot (no server)
**Create Tenant** (`/vendor/tenants/new`):
- Form with: Name, Slug (auto-generated from name, editable), Tier (dropdown: LOW/MID/HIGH/BUSINESS)
- On submit: shows provisioning progress (creating record → creating org → generating license → starting server → health check → done)
- Progress displayed as a step indicator or timeline
- DataTable: Name, Email, Role (dropdown: Owner/Operator/Viewer), Actions (Remove)
- "+ Invite Member" button → form with email + role
- Role changes update Logto org membership
- Cannot remove the last owner
**Settings** (`/tenant/settings`):
- Read-only info: Name, Slug, Tier, Status, Created
- Server endpoint URL
- "Contact support to change tier" message (tier changes go through vendor)
### Shared Components
- **ServerStatusBadge**: Green dot + "Running", Red dot + "Stopped", Gray dot + "Provisioning"
- **UsageIndicator**: "2 / 3 agents" with progress bar, color-coded (green < 80%, amber < 100%, red = 100%)
- **ProvisioningProgress**: Step indicator for tenant creation flow
### Layout Changes
- Remove TopBar server controls (status filters, time range, auto-refresh) — these are not relevant to the SaaS platform. Use a simplified TopBar with breadcrumb, theme toggle, and user menu only.
- Sidebar: persona-aware navigation (vendor vs customer sections)
- Sidebar footer: "Open Server Dashboard" link with tenant-specific URL (`/t/{slug}/`)
The default `cameleer-server` and `cameleer-server-ui` containers in docker-compose.yml become the "bootstrap" server for the `default` tenant. When provisioning is enabled, new tenants get their own dynamically-created containers.
For the `default` tenant (created by bootstrap), the SaaS recognizes the existing compose-managed server and doesn't try to provision a new one. This is detected by checking if a container named `cameleer-server-default` (or the compose-managed `cameleer-server`) already exists.