chmonitor Cloud (SaaS) mode
One codebase, two products. dash.chmonitor.dev = Cloud; Docker/K8s/self-built
Worker = self-hosted (OSS). The difference is runtime config behind one flag.
Paid self-host path: operators who already run ClickHouse buy a commercial license (yearly/lifetime, host count, honor system). Do not push them to Polar cloud seats. Do not add a license key to the OSS binary.
This is a project skill kept under
.claude/skills/(NOT.agents/skills/, which thebuild:skillsregistry scans for end-user AI-agent skills). Keep dev skills here so they never leak into the agent bundle.
Golden rule
Fail-closed to self-hosted. Unset/junk CHM_CLOUD_MODE / VITE_CLOUD_MODE
→ NOT cloud → OSS unchanged. Cloud behaviour is ADDITIVE only. Never gate a core
monitoring feature behind cloud mode. (Mirrors lib/edition fail-open design.)
The flag
- Resolver:
apps/dashboard/src/lib/cloud/cloud-mode.tsisCloudModeClient()— build-time, React/hooks (readsVITE_CLOUD_MODE).isCloudModeServer(env)— runtimeCHM_CLOUD_MODEwins over build-time.parseCloudMode(v)— only'true'|'1'|'cloud'(trim/case-insensitive) → true.
- Build inline:
vite.config.tsCLIENT_ENV +src/vite-env.d.ts. Each clientVITE_*DERIVES from the canonicalCHM_*(set the value once). - Single source of truth:
apps/dashboard/.env.production(+.env.previewoverlay). It feeds BOTH the vite client build (CHM_BUILD_ENV=production|preview→build:production/build:preview) AND the Worker runtime[vars](apps/dashboard/scripts/patch-wrangler-env.tsinjects the non-VITE_keys).wrangler.tomldeclares NO[vars]— never re-add one; edit.env.production. - Self-hosted uses the same names from
apps/dashboard/.env.example(Dockerenv_file, Helmvalues.yaml). Secrets only viaset-secrets.ts/ K8s Secret.
Cloud mode is a BUILD-TIME contract (#2515). The client bundle only sees the
baked-in VITE_CLOUD_MODE; it never reads runtime env. Booting a prebuilt OSS
image with runtime CHM_DEPLOYMENT_MODE=cloud / CHM_CLOUD_MODE=true splits
the product — server enforces cloud (demo guard) while the client renders OSS UI.
Enable cloud by setting CHM_CLOUD_MODE before the build (so the VITE
derivation runs), not only at runtime. Guard: detectCloudModeMismatch(env) →
{server, clientBuild, mismatch}; /api/healthz warns and reports cloudMode
on mismatch. The reverse (cloud build, runtime unset) is safe — fail-closed.
Behaviour
| Self-hosted | Cloud | |
|---|---|---|
| Env hosts | real, full access | source:'demo', read-only |
| Anonymous | env hosts | the demo |
| Signed-in | env hosts | demo HIDDEN → own D1 connections; zero → welcome/setup |
| Agent (anon) | Clerk-gated if access=authenticated | reachable on demo host via authorizeAgentApiRequest guest wrapper (not CHM_FEATURE_AGENT_ACCESS=public); daily cap 3 + RL 5/min; D1 guest:<ip-hash>; deploy ANYROUTER_API_KEY + anyrouter:auto |
CLI device login (CHM_DEVICE_LOGIN) |
off by default (auto); set true for device-only tokens when auth=none (trusted LAN) |
on when CHM_API_KEY_SECRET is set; /device needs Clerk session |
Resolver: lib/auth/device-login-config.ts. Store: D1 or in-memory
(lib/auth/device-code-store.ts). See docs/knowledge/standalone-cli.md.
CLI auth discovery: GET /api/v1/auth/cli (public) returns
method none|device|api_key for chm auth login — no auth_mode in
CLI config. Code: lib/auth/cli-auth-discovery.ts.
Welcome / setup page
components/host/first-run-empty-state.tsx renders 3 variants by
(cloudMode, isSignedIn): cloud signed-in (Connect-your-host + Add-host dialog),
cloud anon (sign-in + value prop), self-hosted (env-var guidance + browser add).
Gate ClerkSignInButton behind isClerkEnabled().
Human vs agent auth URLs (#3092). /sign-in and /sign-up are human HTML
pages (components/clerk/auth-page.tsx: Clerk modal CTA, or an explicit
"use the dashboard Sign in control" fallback when Clerk is off). /login
redirects to /sign-in. /auth.md is the agent-facing markdown document.
agentDiscoveryHandler in start.ts must never rewrite the human auth
paths to text/markdown — a cached markdown HIT is what made
dash.chmonitor.dev/sign-in unusable for people.
Cloud signed-in welcome: connect a host immediately. There is no Polar
plan picker and no dashboard /billing page. Public paid checkout is
self-host licenses on chmonitor.dev/pricing → hooks.chmonitor.dev/checkout/license.
Host/seat counts are not enforced. OSS fails open.
"Try with sample ClickHouse" preset — a DIFFERENT thing from the demo host
above (server env-configured, cloud-only): a one-click preset any user (OSS or
cloud signed-in) can add through the NORMAL add-host path, for the "must own a
cluster to try it" barrier. Not shown to cloud anon visitors (they already get
the automatic demo). components/connections/sample-preset.ts is the single
constant (SAMPLE_CLUSTER_PRESET + isSampleClusterHost) — currently the
public ClickHouse Playground (play.clickhouse.com/explorer, non-secret,
DDL/INSERT rejected server-side); that shared demo also denies several
system.* tables chmonitor needs (query_log, parts, merges, processes,
replicas, mutations, disks, errors), so operational pages show their normal
empty/error states against it — schema browsing, metrics/settings/functions,
and SQL/AI chat work. add-host-dialog.tsx's initialPreset?: 'sample' must be
set explicitly (incl. undefined) on every open — the dialog is reused, not
remounted per-CTA. components/host/sample-cluster-banner.tsx is the
dismissible "Connect your own cluster" convert nudge shown once the sample is
connected. Full detail: docs/knowledge/cloud-saas-mode.md.
Guest AI credits (Cloud only)
Anonymous Cloud visitors can chat on the demo host. Reachability is
not CHM_FEATURE_AGENT_ACCESS=public (agent is operation: 'write';
Clerk public-read still 401s unsigned POSTs). authorizeAgentApiRequest
allows unsigned Cloud + public-read on agent POST / models / config-check /
followups only. UI: agent-auth-gate.tsx ensureAuthed() true for Cloud
unsigned; OSS Clerk stays gated.
Guests bill the deploy ANYROUTER_API_KEY and default to anyrouter:auto.
Server strips BYOK apiKey, ignores mcpServers / user MCP, allowlists
auto + free, hostId env/demo only. Conversations stay Clerk-only (local
thread for unsigned Cloud).
They get a dedicated daily message cap (CHM_GUEST_AI_REQUESTS_PER_DAY,
default 3) and a tighter per-identity rate limit
(RATE_LIMIT_AGENT_GUEST_PER_MIN, default 5) so they cannot burn the
shared AnyRouter key. Usage is stored in the existing D1 ai_usage_daily
table under guest:<sha256-ip-prefix> — never the literal owner id
guest. GET /api/v1/billing/usage returns a slim Guest payload for
unsigned Cloud callers so the quota chip can render. OSS skips this (IP RL
only, no daily gate). Helpers: lib/billing/guest-ai.ts. Gate:
applyAiUsageGate in routes/api/v1/-agent/billing.ts. 402 reason:
guest_daily_limit (sign in for more — no Polar jargon).
Connection-error help
lib/connection-errors.ts:
classifyConnectionError(raw)→{kind,title,explanation,fix,docsSlug,raw}. Kinds: host_not_allowed, invalid_url, auth_failed, access_denied, dns_error, connection_refused, tls_error, timeout, mixed_content, unknown. Add new patterns by extendingRULES(first match wins, specific first).extractConnectionErrorMessage(body)handles{error:string}(test route) AND{error:{message}}(shared validation builder).- Rendered by
ConnectionErrorPanelinconnection-form.tsx. - Docs page slug:
guides/connection-errors(docs/content/guide/guides/connection-errors.mdx).
Build/test gotchas
apps/dashboardis NOT a root pnpm workspace member →cd apps/dashboard && pnpm install.- Build inside
apps/dashboard:pnpm run build(vite + tsc --noEmit). - Tests:
bun test src/lib/cloud src/lib/connection-errors.test.ts. - Full reference:
docs/knowledge/cloud-saas-mode.md.
Keep this skill current
When you change cloud-mode behaviour, demo-host visibility, the welcome/setup
page, human /sign-in vs agent /auth.md, per-user connections, the
connection-error classifier, or guest-agent credits/rate-limits, UPDATE this
file and docs/knowledge/cloud-saas-mode.md in the same change. See the
"Auto-improve project skills" note in the root CLAUDE.md.