# Cameleer Ecosystem Architecture Review **Date:** 2026-04-07 **Status:** Ready for review **Scope:** cameleer (agent), cameleer-server, cameleer-saas **Focus:** Responsibility boundaries, architectural fitness, simplification opportunities **Not in scope:** Security hardening, code quality, performance --- ## Executive Summary The cameleer ecosystem has a clear vision: a standalone observability and runtime platform for Apache Camel, optionally managed by a thin SaaS vendor layer. Both deployment modes must be first-class. The agent (cameleer) is architecturally clean. Single job, well-defined protocol. The server (cameleer-server) is solid for observability but currently lacks runtime management capabilities (deploying and managing Camel application containers). These capabilities exist in the SaaS layer today but belong in the server, since standalone customers also need them. The SaaS layer (cameleer-saas) has taken on too many responsibilities: environment management, app lifecycle, container orchestration, direct ClickHouse access, and partial auth duplication. It should be a thin vendor management plane: onboard tenants, provision server instances, manage billing. Nothing more. **The revised direction:** - **Server layer** = the product. Observability + runtime management + auth/RBAC. Self-sufficient standalone, or managed by SaaS. - **SaaS layer** = vendor management plane. Owns tenant lifecycle (onboard, offboard, bill), provisions server instances, communicates exclusively via server REST APIs. - **Strong data separation.** Each layer has its own dedicated PostgreSQL and ClickHouse. No cross-layer database access. - **Logto as federation hub.** In SaaS mode, Logto handles all user authentication. Customers bring their own OIDC providers via Logto Enterprise SSO connectors. --- ## What's Working Well ### Agent (cameleer) - Clean separation: core logic in `cameleer-core`, protocol models in `cameleer-common`, delivery mechanisms (agent/extension) as thin wrappers - Well-defined agent-server protocol (PROTOCOL.md) with versioning - Dual-mode design (Java agent + Quarkus extension) is elegant - Compatibility matrix across 40 Camel versions demonstrates maturity - No changes needed ### Server (cameleer-server) - Two-database pattern (PostgreSQL control plane, ClickHouse observability data) is correct - In-memory agent registry with heartbeat-based auto-recovery is operationally sound - `cameleer-server-core` / `cameleer-server-app` split keeps domain logic framework-free - SSE command push with Ed25519 signing is well-designed - The UI is competitive-grade (per UX audit #100) - Independent user/group/role management works for standalone deployments ### SaaS (cameleer-saas) - Logto as identity provider was a good buy-vs-build decision - The async deployment pipeline (DeploymentExecutor) is well-implemented (will migrate to server) - Tenant isolation interceptor is a solid pattern - M2M token infrastructure (ServerApiClient) is the right integration pattern - The dual-deployment architecture document shows strong strategic thinking ### Cross-cutting - MOAT features (debugger, lineage, correlation) correctly planned as agent+server features - Design doc discipline provides good decision traceability - Gitea issues show clear product thinking and prioritization --- ## Current Architectural Problems ### Problem 1: Environment and app management lives in the wrong layer **What happens today:** - The SaaS has `EnvironmentEntity`, `AppEntity`, `DeploymentEntity` — full environment/app lifecycle management - The server treats `applicationId` and `environmentId` as opaque strings from agent heartbeats - The server has no concept of "deploy an app" or "create an environment" **Why this is wrong:** - Standalone customers need environment and app management too. Without the SaaS, they have no way to deploy Camel JARs through a UI. - The server is the product. Runtime management is a core product capability, not a SaaS add-on. - The SaaS should provision a server instance for a tenant and then get out of the way. The tenant interacts with their server instance directly. **What should happen:** - Environment CRUD, app CRUD, JAR upload, deployment lifecycle — all move to the server. - The server gains a `RuntimeOrchestrator` interface with auto-detected implementations: - Docker socket available → Docker mode (image build, container lifecycle) - K8s service account available → K8s mode (Deployments, Kaniko builds) - Neither → observability-only mode (agents connect externally, no managed runtime) - One deployable. Adapts to its environment. Standalone customer mounts Docker socket and gets full runtime management. --- ### Problem 2: SaaS bypasses the server to access its databases **What happens today:** - `AgentStatusService` queries the server's ClickHouse directly (`SELECT count(), max(start_time) FROM executions`) - `ContainerLogService` creates and manages its own `container_logs` table in the server's ClickHouse - The SaaS has its own ClickHouse connection pool (HikariCP, 10 connections) **Why this is wrong:** - Violates the exclusive data ownership principle. The server owns its ClickHouse schema. - Schema changes in the server silently break the SaaS. - Creates tight coupling where there should be a clean API boundary. - Two connection pools to ClickHouse from different services adds unnecessary operational complexity. **What should happen:** - The SaaS has zero access to the server's databases. All data flows through the server's REST API. - Container logs (a runtime concern) move to the server along with runtime management. - The SaaS has its own PostgreSQL for vendor concerns (tenants, billing, provisioning records). No ClickHouse needed. --- ### Problem 3: Auth architecture doesn't support per-tenant OIDC **What happens today:** - The server has one global OIDC configuration - In SaaS mode, it validates Logto tokens. All tenants use the same Logto instance. - Customers cannot bring their own OIDC providers (Okta, Azure AD, etc.) - The server generates its own JWTs after OIDC callback, creating dual-issuer problems (#38) - `syncOidcRoles` writes shadow copies of roles to PostgreSQL on every login **Why this matters:** - Enterprise customers require SSO with their own identity provider. This is table-stakes for B2B SaaS. - The dual-issuer pattern (server JWTs + Logto JWTs) causes the session synchronization problem (#38). - Per-tenant OIDC needs a federation hub, not per-server OIDC config changes. **What should happen:** - **Standalone mode:** Server manages users/groups/roles independently. Optional OIDC integration pointing to customer's IdP directly. Works exactly as today. - **SaaS mode:** Logto acts as federation hub via Enterprise SSO connectors. Each tenant/organization configures their own SSO connector (SAML or OIDC). Logto handles federation and issues a single token type. The server validates Logto tokens (one OIDC config). Single token issuer eliminates #38. - **Server auth behavior (inferred from config, no explicit mode flag):** - No OIDC configured: Full local auth. Server generates JWTs, manages users/groups/roles. - OIDC configured: Local + OIDC coexist. Claim mapping available. - OIDC configured + `cameleer.auth.local.enabled=false`: Pure resource server. No local login, no JWT generation, no shadow role sync. SaaS provisioner sets this. --- ### Problem 4: License validation has no server-side implementation **What exists today:** - The SaaS generates Ed25519-signed license JWTs with tier/features/limits - The server has zero license awareness — no validation, no feature gating, no tier concept - MOAT features cannot be gated at the server level **What should happen:** - Server validates Ed25519-signed license JWTs - License loaded from: env var, file path, or API endpoint - MOAT feature endpoints check license before serving data - In standalone: license file at `/etc/cameleer/license.jwt` - In SaaS: license injected during tenant provisioning --- ## Revised Architecture ### Layer Separation ``` SAAS LAYER (vendor management plane) Owns: tenants, billing, provisioning, onboarding/offboarding Storage: own PostgreSQL (vendor data only) Auth: Logto (SaaS vendor UI, tenant SSO federation hub) Communicates with server layer: exclusively via REST API SERVER LAYER (the product) Owns: observability, runtime management, environments, apps, deployments, users, groups, roles, agent protocol, licenses Storage: own PostgreSQL (RBAC, config, app metadata) + own ClickHouse (traces, metrics, logs) Auth: standalone (local + optional OIDC) or oidc-only (validates external tokens) Multi-tenancy: tenant_id-scoped data access in shared PG + CH AGENT LAYER Owns: instrumentation, data collection, command execution Communicates with server: via PROTOCOL.md (HTTP + SSE) ``` ### Deployment Models **Standalone (single tenant):** ``` Customer runs one server instance. Server manages its own users, apps, environments, deployments. Server connects to its own PG + CH. No SaaS involvement. No Logto. Optional: customer configures OIDC to their corporate IdP. License: file-based or env var. ``` **SaaS (multi-tenant):** ``` Vendor runs the SaaS layer + Logto + shared PG (vendor) + shared PG (server) + shared CH. SaaS provisions one server instance per tenant. Each server instance is scoped to one tenant_id. All server instances share PG + CH (tenant_id partitioning). Auth: Logto federation hub. Per-tenant Enterprise SSO connectors. Server runs in oidc-only mode, validates Logto tokens. SaaS communicates with each server instance via REST API (M2M token). License: injected by SaaS during provisioning, pushed via server API. ``` ### SaaS Tenant Onboarding Flow ``` 1. Vendor creates tenant in SaaS layer (name, slug, tier, billing) 2. SaaS creates Logto organization (maps 1:1 to tenant) 3. SaaS generates Ed25519-signed license JWT (tier, features, limits, expiry) 4. SaaS provisions server instance: - Docker mode: start container with tenant_id + license + OIDC config - K8s mode: create Deployment in tenant namespace 5. SaaS calls server API to verify health 6. Tenant admin logs in via Logto (federated to their SSO if configured) 7. Tenant admin uploads Camel JARs, manages environments, deploys apps — all via server UI 8. SaaS only re-engages for: billing, license renewal, tier changes, offboarding ``` ### Runtime Orchestration in the Server The server gains runtime management, auto-detected by environment: ```java RuntimeOrchestrator (interface) + createEnvironment(name, config) -> Environment + deployApp(envId, jar, config) -> Deployment + stopApp(appId) -> void + restartApp(appId) -> void + getAppLogs(appId, since) -> Stream + getAppStatus(appId) -> AppStatus DockerRuntimeOrchestrator - Activated when /var/run/docker.sock is accessible - docker-java for image build + container lifecycle - Traefik labels for HTTP routing KubernetesRuntimeOrchestrator - Activated when K8s service account is available - fabric8 for Deployments, Services, ConfigMaps - Kaniko for image builds DisabledRuntimeOrchestrator - Activated when neither Docker nor K8s is available - Observability-only mode: agents connect externally - Runtime management endpoints return 404 ``` ### Auth Architecture Auth mode is inferred from configuration — no explicit mode flag. **No OIDC configured → standalone:** - Server manages users, groups, roles in its own PostgreSQL - Local login via username/password - Server generates JWTs (HMAC-SHA256) - Agent auth: bootstrap token → JWT exchange **OIDC configured → standalone + OIDC:** - Local auth still available alongside OIDC - OIDC users auto-signup on first login - Claim mapping available for automated role/group assignment - Server generates JWTs for both local and OIDC users - Agent auth: bootstrap token → JWT exchange **OIDC configured + local auth disabled (`cameleer.auth.local.enabled=false`) → OIDC-only:** - Server is a pure OAuth2 resource server - Validates external JWTs (Logto or any OIDC provider) - Reads roles from configurable JWT claim (`roles`, `scope`, etc.) - No local login, no JWT generation, no user table writes on login - Agent auth: bootstrap token → JWT exchange (agents always use server-issued tokens) - In SaaS mode, the SaaS provisioner sets `cameleer.auth.local.enabled=false` **Per-tenant OIDC in SaaS (via Logto Enterprise SSO):** ``` Tenant A (uses Okta): User → Logto → detects email domain → redirects to Okta → authenticates → Okta returns assertion → Logto issues JWT with org context → Server validates Logto JWT (one OIDC config for all tenants) Tenant B (uses Azure AD): User → Logto → detects email domain → redirects to Azure AD → authenticates → Azure AD returns assertion → Logto issues JWT with org context → Server validates same Logto JWT Tenant C (no enterprise SSO): User → Logto → authenticates with Logto credentials directly → Logto issues JWT with org context → Server validates same Logto JWT ``` Single token issuer (Logto). Single server OIDC config. Per-tenant SSO handled entirely in Logto. Eliminates dual-issuer problem (#38). --- ## What Moves Where ### From SaaS to Server | Component | Current Location | New Home | Notes | |-----------|-----------------|----------|-------| | `EnvironmentEntity` + CRUD | SaaS | Server | First-class server concept | | `AppEntity` + CRUD | SaaS | Server | First-class server concept | | `DeploymentEntity` + lifecycle | SaaS | Server | First-class server concept | | `DeploymentExecutor` | SaaS | Server | Async deployment pipeline | | `DockerRuntimeOrchestrator` | SaaS | Server | Docker mode runtime | | JAR upload + image build | SaaS | Server | Runtime management | | Container log collection | SaaS | Server | Part of runtime management | | `AgentStatusService` | SaaS | Removed | Server already has this natively | | `ContainerLogService` | SaaS | Server | Logs stored in server's ClickHouse | ### Stays in SaaS | Component | Why | |-----------|-----| | `TenantEntity` + lifecycle | Vendor concern: onboarding, offboarding | | `LicenseService` (generation) | Vendor signs licenses | | Billing integration (Stripe) | Vendor concern | | Logto bootstrap + org management | Vendor concern | | `ServerApiClient` | SaaS → server communication (grows in importance) | | Audit logging (vendor actions) | Vendor concern | ### Stays in Server | Component | Why | |-----------|-----| | Agent protocol (registration, heartbeat, SSE) | Core product | | Observability pipeline (ingestion, storage, querying) | Core product | | User/group/role management | Must work standalone | | OIDC integration | Must work standalone | | Dashboard, route diagrams, execution detail | Core product | | Ed25519 config signing | Agent security | | License validation (new) | Feature gating | ### Removed Entirely from SaaS | Component | Why | |-----------|-----| | `ClickHouseConfig` + connection pool | SaaS must not access server's CH | | `ClickHouseProperties` | No ClickHouse in SaaS | | `AgentStatusService.getObservabilityStatus()` | Server API replaces this | | `container_logs` table in CH | Moves to server with runtime management | | Environment/App/Deployment entities | Move to server | --- ## What the SaaS Becomes After migration, the SaaS layer is small and focused: ``` cameleer-saas/ ├── tenant/ Tenant CRUD, lifecycle (PROVISIONING → ACTIVE → SUSPENDED → DELETED) ├── license/ License generation (Ed25519-signed JWTs) ├── billing/ Stripe integration (subscriptions, webhooks, tier changes) ├── identity/ Logto org management, Enterprise SSO configuration ├── provisioning/ Server instance provisioning (Docker / K8s) ├── config/ Security, SPA routing ├── audit/ Vendor action audit log └── ui/ Vendor management dashboard (tenant list, billing, provisioning status) ``` **SaaS API surface shrinks to:** - `POST/GET /api/tenants` — tenant CRUD (vendor admin) - `POST/GET /api/tenants/{id}/license` — license management - `POST /api/tenants/{id}/provision` — provision server instance - `POST /api/tenants/{id}/suspend` — suspend tenant - `DELETE /api/tenants/{id}` — offboard tenant - `GET /api/tenants/{id}/status` — server instance health (via server API) - `GET /api/config` — public config (Logto endpoint, scopes) - Billing webhooks (Stripe) Everything else (environments, apps, deployments, observability, user management) is the server's UI and API, accessed directly by the tenant. --- ## Migration Path | Order | Action | Effort | Notes | |-------|--------|--------|-------| | 1 | Add Environment/App/Deployment entities + CRUD to server | Medium | Port from SaaS, adapt to server's patterns | | 2 | Add RuntimeOrchestrator interface + DockerRuntimeOrchestrator to server | Medium | Port from SaaS, add auto-detection | | 3 | Add JAR upload + image build pipeline to server | Medium | Port DeploymentExecutor | | 4 | Add container log collection to server | Small | Part of runtime management | | 5 | Add server API endpoints for app/env management | Medium | REST controllers + UI pages | | 6 | Add `oidc-only` auth mode to server | Medium | Resource server mode | | 7 | Implement server-side license validation | Medium | Ed25519 JWT validation + feature gating | | 8 | Strip SaaS down to vendor management plane | Medium | Remove migrated code, simplify | | 9 | Remove ClickHouse dependency from SaaS entirely | Small | Delete config, connection pool, queries | | 10 | Write SAAS-INTEGRATION.md | Small | Document server API contract for SaaS | Steps 1-5 can be developed as a "runtime management" feature in the server. Steps 6-7 are independent server features. Step 8 is the SaaS cleanup after server capabilities are in place. --- ## Issue Triage Notes ### Issues resolved by this architecture: - **saas#38 (session management):** Eliminated — single token issuer in SaaS mode - **server#100 (UX audit):** Server UI gains full runtime management, richer experience - **server#122 (ClickHouse scaling):** SaaS no longer a ClickHouse client - **saas#7 (license & feature gating):** Server-side license validation - **saas#37 (admin tenant creation UI):** SaaS UI becomes vendor-focused, simpler ### Issues that become more important: - **agent#33 (version cameleer-common independently):** Critical before server API contract stabilizes - **server#46 (OIDC PKCE for SPA):** Required for server-ui in oidc-only mode - **server#101 (onboarding experience):** Server UI needs guided setup for standalone users ### Issues unaffected: - **MOAT epics (#57-#72):** Correctly scoped as agent+server. License gating is the prerequisite. - **UX audit P0s (#101-#103):** PMF-critical. Independent of architectural changes. - **Agent transport/security (#13-#15, #52-#54):** Agent concerns, unrelated. --- ## User Management Model ### RBAC Structure The server has a classical RBAC model with users, groups, and roles. All stored in the server's PostgreSQL. **Entities:** - **Users** — identity records (local or OIDC-sourced) - **Groups** — organizational units; can nest (parent_group_id). Users belong to groups. - **Roles** — permission sets. Attached to users (directly) or groups (inherited by members). - **Permissions** — what a role allows (view executions, send commands, manage config, admin, deploy apps, etc.) **Built-in roles** (system roles, cannot be deleted): - `VIEWER` — read-only access to observability data and runtime status - `OPERATOR` — VIEWER + send commands, edit config, deploy/manage apps - `ADMIN` — full access including user/group/role management, server settings, license Custom roles may be defined by the server admin for finer-grained control. ### Assignment Types Every user-role and user-group assignment has an **origin**: | Origin | Set by | Lifecycle | On OIDC login | |--------|--------|-----------|---------------| | `direct` | Admin manually assigns via UI/API | Persisted until admin removes it | Untouched | | `managed` | Claim mapping rules evaluate JWT | Recalculated on every OIDC login | Cleared and re-evaluated | Effective permissions = union of direct roles + managed roles + roles inherited from groups (both direct and managed group memberships). **Schema:** ```sql -- user_roles user_id UUID NOT NULL role_id UUID NOT NULL origin VARCHAR NOT NULL -- 'direct' or 'managed' mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed -- user_groups (same pattern) user_id UUID NOT NULL group_id UUID NOT NULL origin VARCHAR NOT NULL -- 'direct' or 'managed' mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed ``` ### Claim Mapping When OIDC is configured, the server admin can define **claim mapping rules** that automatically assign roles or group memberships based on JWT claims. Rules are server-level config (one set per server instance = effectively per-tenant in SaaS mode). **Rule structure:** ```sql -- claim_mapping_rules id UUID PRIMARY KEY claim VARCHAR NOT NULL -- JWT claim to read (e.g., 'groups', 'roles', 'department') match_type VARCHAR NOT NULL -- 'equals', 'contains', 'regex' match_value VARCHAR NOT NULL -- value to match against action VARCHAR NOT NULL -- 'assignRole' or 'addToGroup' target VARCHAR NOT NULL -- role name or group name priority INT DEFAULT 0 -- evaluation order (higher = later, for conflict resolution) ``` **Examples:** ```json [ { "claim": "groups", "match": "contains", "value": "cameleer-admins", "action": "assignRole", "target": "ADMIN" }, { "claim": "groups", "match": "contains", "value": "integration-team", "action": "addToGroup", "target": "Integration Developers" }, { "claim": "department", "match": "equals", "value": "ops", "action": "assignRole", "target": "OPERATOR" } ] ``` **Login flow with claim mapping:** ``` 1. User authenticates via OIDC 2. Server receives JWT with claims 3. Auto-signup: create user record if not exists (with configurable default role) 4. Clear all MANAGED assignments for this user: DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed' DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed' 5. Evaluate claim mapping rules against JWT claims: For each rule (ordered by priority): Read claim value from JWT If match_type matches match_value: Insert MANAGED assignment (role or group) 6. User's effective permissions are now: direct roles + managed roles + group-inherited roles ``` ### How It Scales | Size | OIDC | Claim mapping | Admin work | |------|------|---------------|------------| | Solo / small (1-10) | No, local auth | N/A | Create users, assign roles manually | | Small + OIDC (5-20) | Yes | Not needed | Auto-signup with default VIEWER. Admin promotes key people (direct assignments) | | Medium org (20-100) | Yes | 3-5 claim-to-role rules | One-time setup. Most users get roles automatically. Manual overrides for exceptions | | Large org (100+) | Yes | Claim-to-group mappings | One-time setup. IdP groups → server groups → roles. Fully automated, self-maintaining | | SaaS (Logto) | Enforced | Default mapping: `roles` claim → server roles | Vendor sets defaults. Customer configures SSO claim structure, then adds mapping rules | ### Standalone vs SaaS Behavior **Standalone (no OIDC configured):** - Full local user management: create users, set passwords, assign roles/groups - Admin manages everything via server UI **Standalone + OIDC (OIDC configured and enabled):** - Local user management still available - OIDC users auto-signup on first login (configurable: on/off, default role) - Claim mapping available for automated role/group assignment - Both local and OIDC users coexist **SaaS / OIDC-only (OIDC configured and enabled, local auth disabled):** - Inferred: when OIDC is configured and enabled, the server operates as a pure resource server - No local user creation or password management - Users exist only after first OIDC login (auto-signup always on) - Claim mapping is the primary role assignment mechanism - Admin can still make direct assignments via UI (for overrides) - User/password management UI sections hidden - Logto org roles → JWT `roles` claim → server claim mapping → server roles **Note:** There is no explicit `cameleer.auth.mode` flag. The server infers its auth behavior from whether OIDC is configured and enabled. If OIDC is present, the server acts as a resource server for user-facing auth (agents always use server-issued tokens regardless). ### SaaS with Enterprise SSO (per-tenant customer IdPs) ``` Customer uses Okta: Okta JWT contains: { "groups": ["eng", "cameleer-admins"], "department": "platform" } → Logto Enterprise SSO federates, forwards claims into Logto JWT → Server evaluates claim mapping rules: "groups contains cameleer-admins" → ADMIN role (managed) "department equals platform" → add to "Platform Team" group (managed) → User gets: ADMIN + Platform Team's inherited roles Customer uses Azure AD: Azure AD JWT contains: { "roles": ["CameleerOperator"], "jobTitle": "Integration Developer" } → Same flow, different mapping rules configured by that tenant's admin: "roles contains CameleerOperator" → OPERATOR role (managed) ``` Each tenant configures their own mapping rules in their server instance. The server doesn't care which IdP issued the claims — it just evaluates rules against whatever JWT it receives. --- ## Resolved Design Questions ### Server Instance Topology - `CAMELEER_TENANT_ID` env var (already exists) scopes all data access in PG and CH - Standalone: defaults to `"default"`, customer never thinks about it - SaaS: the SaaS provisioner sets it when starting the server container - Auth behavior is inferred from OIDC configuration (no explicit mode flag) - The server doesn't need to "know" it's in SaaS mode — tenant_id + OIDC config is sufficient ### Runtime Orchestrator Scope (Routing) The server owns routing as part of the RuntimeOrchestrator abstraction. Two routing strategies, configured at the server level: ``` cameleer.routing.mode=path → api.example.com/apps/{env}/{app} (default, works everywhere) cameleer.routing.mode=subdomain → {app}.{env}.apps.example.com (requires wildcard DNS + TLS) ``` - **Path-based** is the default — no wildcard DNS or TLS required, works in every environment - **Subdomain-based** is opt-in for customers who prefer it and can provide wildcard infrastructure - Docker mode: Traefik labels on deployed containers - K8s mode: Service + Ingress resources - The routing mechanism is an implementation detail of each RuntimeOrchestrator, not a separate concern ### UI Navigation The current server navigation structure is preserved. Runtime management integrates as follows: - **Environment management** → Admin section (high-privilege task, not daily workflow) - **Applications** → New top-level nav item (app list, deploy, JAR upload, container status, deployment history) - **Observability pages** (Exchanges, Dashboard, Routes, Logs) → unchanged Applications page design requires a UI mock before finalizing — to be explored in the frontend design phase. ### Data Migration Not applicable — greenfield. No existing installations to migrate. --- ## Summary The cameleer ecosystem is well-conceived but the current SaaS-server boundary is in the wrong place. The SaaS has grown into a second product rather than a thin vendor layer. The fix is architectural: move runtime management (environments, apps, deployments) into the server, make the SaaS a pure vendor management plane, enforce strict data separation, and use Logto Enterprise SSO as the federation hub for per-tenant OIDC. The result: **the server is the complete product (observability + runtime + auth). The SaaS is how the vendor manages tenants of that product. Both standalone and SaaS are first-class because the server doesn't depend on the SaaS for any of its capabilities.**