Architecture
Overview
Section titled “Overview”ArchiSpark is a multi-tenant SaaS: each organization (“entreprise”) has its own members, teams and workspaces (ArchiMate models). The backend is split into a public control-plane API and an internal tenant-plane API, fronted by a single reverse proxy.
Internet └─ Traefik v3 (reverse proxy, TLS, routing via Docker labels) ├─ /api/* → control-api :3000 (prefix /api stripped; everything not │ owned by control-api is proxied │ on to tenant-api) ├─ /auth/* → control-api :3000 (Better Auth) ├─ /mcp/* → mcp-server :3001 (stateless Streamable HTTP) └─ /* → Next.js web :8000
tenant-api :3002 is internal-only — reached over the Docker network athttp://tenant-api:3002, never exposed by Traefik.
admin-web :8001 is a separate Next.js app, deployed independently(e.g. its own Vercel project / subdomain) — see Admin web below.Services
Section titled “Services”| Service | Port | Role |
|---|---|---|
| Traefik | 80 / 443 | Reverse proxy, routing via Docker labels, optional TLS (Let’s Encrypt) |
| control-api | 3000 | Express 5 — Better Auth, sessions, organizations/teams, users, API tokens, platform admin routes. Public entry point; reverse-proxies everything else to tenant-api |
| tenant-api | 3002 (internal) | Express 5 — workspaces, elements, relationships, views, property definitions, model import/export, /openapi.json, /docs |
| mcp-server | 3001 | 38 ArchiMate tools via stateless Streamable HTTP |
| web | 8000 | Next.js — customer-facing UI |
| admin-web | 8001 | Next.js — platform admin console (platform_admin only) |
| PostgreSQL | 5432 | Control-plane database (+ tenant-plane for orgs still on the shared DB) |
| Redis | 6379 | Required — session store + distributed rate-limiting |
Shared packages
Section titled “Shared packages”| Package | Role |
|---|---|
@workspace/db | Drizzle schemas (control + tenant), DB connections, migrations, tenant provisioning, ArchiMate types |
@workspace/types | Shared types across API ↔ Web |
@workspace/ui | shadcn/ui component library |
Control-api / tenant-api split
Section titled “Control-api / tenant-api split”apps/control-api is the single public entry point. For every request it doesn’t own itself (workspaces, elements, relationships, views, property definitions, model import/export, /openapi.json, /docs), it:
- Runs
requireAuth→resolveWorkspaceContext→requireWorkspaceWrite(so401/403happen without ever reaching tenant-api). - Signs a short-lived (60s) inter-service JWT with
TENANT_JWT_SECRET. - Forwards the request to
TENANT_API_URLwithAuthorization: Bearer <jwt>.
tenant-api verifies the JWT and reconstructs req.user / req.workspace from its claims:
{ sub, username, platform_role, organization_id, org_role, team_ids, tenant_db }tenant_db is the organization’s encrypted Neon connection string (null if the organization still shares the control-plane database). Only tenant-api and mcp-server hold TENANT_DB_ENCRYPTION_KEY to decrypt it.
Self-hosted Docker: tenant-api runs as an internal-only Compose service (no Traefik labels). Vercel: tenant-api is its own project, reached at its default *.vercel.app URL.
Authentication flow
Section titled “Authentication flow”1. POST /auth/sign-in/username → control-api → Better Auth2. httpOnly cookie "better-auth.session_token" is set3. Each request → requireAuth (cookie or Bearer api_tokens)4. resolveWorkspaceContext → active organization + org role + team ids5. requireWorkspaceWrite → 403 for org role "member" on POST/PUT/DELETE6. Routes owned by tenant-api → signed inter-service JWT → proxiedSee Authentication & Authorization for the full role model.
Session caching
Section titled “Session caching”Better Auth validates sessions on every authenticated request. Two caching layers reduce database pressure:
| Mode | Layer 1 | Layer 2 | DB hit |
|---|---|---|---|
| Without Redis | Cookie cache (5 min, per browser) | — | On cache miss |
| With Redis | — | Redis (shared, server-side) | On Redis miss |
When REDIS_URL is set, the cookie cache is disabled and Redis becomes the single shared cache for all control-api replicas.
Rate limiting
Section titled “Rate limiting”express-rate-limit protects control-api’s /auth/* endpoints (50 req / 15 min) and tenant-api’s /import endpoint (10 req / min).
| Mode | Store | Behaviour with multiple replicas |
|---|---|---|
| Without Redis | In-memory (per process) | Each replica has independent counters |
| With Redis | rate-limit-redis | Counters shared — limits enforced globally regardless of replica count |
Multi-tenant database architecture
Section titled “Multi-tenant database architecture”The schema is split into two logical parts.
Control-plane (schema.control.ts) — shared database
Section titled “Control-plane (schema.control.ts) — shared database”| Table | Description |
|---|---|
organizations | Organization entity (id, name, slug, enabled suspend flag) |
teams / team_members | Teams within an organization and their membership |
members | Organization membership — userId ↔ organizationId, role: owner|admin|member |
invitations | Pending org/team invitations |
users | Platform users — username, role: user|platform_admin |
sessions / accounts / verifications | Better Auth session, credential and verification tables |
api_tokens | Bearer tokens for REST/MCP — per user, per organization, optionally per workspace |
oauth_providers | Configured OAuth/OIDC providers (admin web) |
site_settings | Login message / site-wide banner |
tenant_databases | Registry mapping an organization to its dedicated Neon database (status: pending|provisioning|active|error) |
Tenant-plane (schema.tenant.ts) — per-organization
Section titled “Tenant-plane (schema.tenant.ts) — per-organization”| Table | Description |
|---|---|
workspaces | ArchiMate models — uuid, name, description, organization_id |
workspace_teams | Restricts a workspace’s visibility to specific teams |
user_active_workspace | Each user’s active workspace per organization |
elements / element_properties | ArchiMate elements + custom properties |
relationships / relationship_properties | ArchiMate relationships + custom properties |
property_definitions | Custom property schemas |
views / nodes / connections / bendpoints | Diagrams, visual nodes, connectors and routing waypoints |
By default every organization’s tenant-plane tables live in the shared control-plane database. An organization is moved to its own dedicated PostgreSQL database (Neon) by adding an active row in tenant_databases — until then, getTenantDb/db fall back to the shared database.
Provisioning tenant databases (Neon)
Section titled “Provisioning tenant databases (Neon)”Set NEON_API_KEY and NEON_PROJECT_ID (control-api only) to enable per-tenant Neon databases. NEON_BRANCH_ID is optional.
- New organizations:
provisionTenantDatabase(organizationId)runs automatically via the Better AuthafterCreateOrganizationhook — creates a dedicated Neon role + database, runs the tenant-only migrations, seeds an empty “Default” workspace, and markstenant_databases"active". - Existing organizations:
pnpm --filter control-api migrate-tenant <organizationId>(or--all) copies all workspaces into a newly provisioned dedicated database, then--cleanup --yesremoves the now-redundant rows from the shared database.
Tenant databases use drizzle-orm/neon-serverless (websocket Pool, supports interactive transactions). The control-plane database always uses drizzle-orm/node-postgres (or PGlite in tests).
Admin web
Section titled “Admin web”apps/admin-web (port 8001) is a separate Next.js app restricted to platform_admin users — anyone else is redirected to /login. It shares the Better Auth session with apps/web (see Cross-subdomain sessions). Pages: /organizations (cross-tenant org management + tenant monitoring), /users, /authentication (OAuth providers), /redis, /postgres, /messages.
MCP (stateless)
Section titled “MCP (stateless)”Claude / LLM → POST https://archispark.com/mcp/ (Authorization: Bearer <api-token>) → Traefik → mcp-server :3001 → lookupApiToken → getMembershipContext (resolves organization + org role) → fresh McpServer + StreamableHTTPServerTransport for THIS request only → tools read/write tenant-api's `store` directly (same DB/JWT path as tenant-api)The server is stateless: no mcp-session-id, every request gets its own McpServer/transport instance. This is deliberate for serverless (Vercel) — an in-memory session map can’t be shared across Lambda instances. There is no sticky-session requirement when scaling to multiple mcp-server replicas.
PostgreSQL schema management
Section titled “PostgreSQL schema management”cd packages/dbDB_DRIVER=postgres npx drizzle-kit generate # control-plane → drizzle-pg/DB_DRIVER=postgres npx drizzle-kit generate --config drizzle.config.tenant.ts # tenant-plane → drizzle-pg/tenant/Migrations are applied automatically on every control-api/tenant-api startup.
Scaling path
Section titled “Scaling path”Phase 1 (Demo) → Single shared Postgres + Redis, all orgs on control-plane DBPhase 2 (MVP) → docker-compose: control-api + tenant-api + mcp-server + web + Traefik + Postgres + RedisPhase 3 (Scale) → Multiple replicas per service behind the load balancerPhase 4 (Isolation)→ Per-organization dedicated Neon database via tenant_databasesAdding Redis requires no code changes — each API detects REDIS_URL at startup and activates the Redis store automatically. MCP replicas need no special load-balancer configuration (stateless).