Browse documentation

The Platform Console is the second entrypoint of apps/server. It runs as its own process and container, serves the Studio bundle at /studio, and exposes /api/platform/* plus an authenticated proxy to every project.

Source: apps/server/console/**. Bootstrap: apps/server/console/server.ts (createConsoleApp()).

Configuration

apps/server/console/config.ts derives everything from the environment. CONSOLE_MODE (dev | prod) drives the defaults:

KeyEnvDefault (dev → prod)
modeCONSOLE_MODEprod
scheme—http → https
domainSuffixPLATFORM_DOMAIN_SUFFIXlocalhost → (empty)
portCONSOLE_PORT5500
jwtSecretCONSOLE_JWT_SECRETdev-console-secret → required
adminEmailCONSOLE_ADMIN_EMAILadmin@example.com
adminPasswordHashCONSOLE_ADMIN_PASSWORD_HASH_B64 / ..._HASH—
adminPasswordTemporaryCONSOLE_ADMIN_PASSWORD_TEMPORARYfalse
projectsDirPROJECTS_DIR.dev-projects → /srv/projects
apiImageAPI_IMAGEmotherbase-api:1.0.0
composeFileCOMPOSE_FILEdeploy/docker-compose.dev-multi.yml → deploy/docker-compose.prod.yml
traefikNetworkTRAEFIK_NETWORKtraefik_proxy
certResolverCERT_RESOLVERletsencrypt
updateCheckUrlUPDATE_CHECK_URL(empty = update checks off)
features.billingCONSOLE_BILLING_ENABLEDfalse

Persisted overrides are merged at boot from <projectsDir>/console-settings.json, synchronously, so every service is constructed against the final values. Runtime edits go through console/services/settings.service.ts and mutate the shared config object live.

Warning

assertConsoleSecret() refuses to start in prod mode without a CONSOLE_JWT_SECRET of at least 32 characters. It runs at bootstrap rather than import time, so tests and tooling can import the config without serving traffic. The shipped compose files already pass ${CONSOLE_JWT_SECRET:?required} — this closes the bare node console/server.js path.

The base64 password hash

A bcrypt hash contains $, which Docker Compose mangles through ${} interpolation in both --env-file and env_file:. The hash is therefore transported base64-encoded as CONSOLE_ADMIN_PASSWORD_HASH_B64; the raw CONSOLE_ADMIN_PASSWORD_HASH remains as a fallback for non-Compose setups. A hash whose $ got doubled by an escaping step is self-healed ($$ → $) rather than failing login.

Authentication

Console auth is Better Auth over its own SQLite store under projectsDir (console/auth/instance.ts), independent from any project's auth:

  • migrateConsoleAuth() ensures the schema exists (idempotent) before serving.
  • createConsoleAuth() builds the instance; the dev secret is padded to 32 chars deterministically, a no-op in prod where a real 32+ char secret is already required.
  • Trusted origins: CONSOLE_BASE_URL, the conventional console.<domainSuffix>, and anything in CONSOLE_TRUSTED_ORIGINS (comma-separated). This guards against proxies that rewrite Host.
  • The first owner is seeded from the env admin (seedOwnerWithHash) when the store is empty.

The login adapter

POST /api/platform/login exists so the UI keeps one login endpoint. It pre-checks the account (exists, not disabled — a Console-only field Better Auth does not gate on), delegates to auth.api.signInEmail, and relays every Set-Cookie.

Two-factor handling is deliberate: the bearer plugin's after-hook runs before the twoFactor plugin's, so set-auth-token can be present but already invalidated. The adapter trusts the authoritative body flag twoFactorRedirect, not the header, and treats a missing token as a second-factor challenge rather than leaking a tokenless success. The UI then posts the TOTP code to /api/auth/two-factor/verify-totp.

Every login attempt — success, invalid credentials, disabled account, second-factor pending, or an authentication error — is written to the audit log.

Middleware and limits

ConcernDetail
trust proxyOpt-in via TRUST_PROXY, same reasoning as the project API
Auth rate limitCONSOLE_RATE_LIMIT_WINDOW_MIN (15) × CONSOLE_RATE_LIMIT_AUTH_MAX (20), 429 with a JSON body
requireSessionAttaches the live identity and the caller's project list
requireRole(...)401 without a session, 403 for a role mismatch (console/middlewares.ts)
requireProjectCapabilityPer-project capability checks (console/auth/capabilities.ts)
enforcePasswordRotationForces a change when mustChangePassword is set

SAFE_METHODS (GET, HEAD, OPTIONS) skip auditing and only need read scope. MIN_PASSWORD is 8 anywhere the Console sets or changes a password.

Mounted surfaces

MountRouter
/api/auth/*Better Auth (before express.json())
/api/platform/loginLogin adapter
/api/platform/usersroutes/users.routes.ts
/api/platform/orgsroutes/orgs.routes.ts
/api/platformroutes/platform.routes.ts — projects, metrics, settings, diagnostics, updates, audit
/apiroutes/proxy.routes.ts — proxy to project APIs
/studioThe Studio bundle, mounted at the Next basePath. Every non-API response carries mb_product=console, which is how the same build knows it is rendering the Platform Console rather than Project Studio
*Everything else, including the bare / and the legacy /console, 308-redirects under /studio

Services constructed at boot

Registry, Orchestrator, DiagnosticsService, UpdateService, MetricsService, AlertsService (started immediately), OrgStore, AuditStore, UsersService, SettingsService, ProjectDefaultsStore, and an OrchestrationClient pointed at ORCHESTRATION_URL (default http://orchestrator:5501) with ORCHESTRATION_SECRET.

On first boot after upgrading a single-tenant install, orgs.backfill() moves existing users and projects into a default org so the org switcher always has something to show. If Docker is unavailable the project list comes back empty and assignment happens lazily on the next boot — the Console still starts.