Skip to content

Architecture

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 at
http://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.
ServicePortRole
Traefik80 / 443Reverse proxy, routing via Docker labels, optional TLS (Let’s Encrypt)
control-api3000Express 5 — Better Auth, sessions, organizations/teams, users, API tokens, platform admin routes. Public entry point; reverse-proxies everything else to tenant-api
tenant-api3002 (internal)Express 5 — workspaces, elements, relationships, views, property definitions, model import/export, /openapi.json, /docs
mcp-server300138 ArchiMate tools via stateless Streamable HTTP
web8000Next.js — customer-facing UI
admin-web8001Next.js — platform admin console (platform_admin only)
PostgreSQL5432Control-plane database (+ tenant-plane for orgs still on the shared DB)
Redis6379Required — session store + distributed rate-limiting
PackageRole
@workspace/dbDrizzle schemas (control + tenant), DB connections, migrations, tenant provisioning, ArchiMate types
@workspace/typesShared types across API ↔ Web
@workspace/uishadcn/ui component library

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:

  1. Runs requireAuthresolveWorkspaceContextrequireWorkspaceWrite (so 401/403 happen without ever reaching tenant-api).
  2. Signs a short-lived (60s) inter-service JWT with TENANT_JWT_SECRET.
  3. Forwards the request to TENANT_API_URL with Authorization: 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.

1. POST /auth/sign-in/username → control-api → Better Auth
2. httpOnly cookie "better-auth.session_token" is set
3. Each request → requireAuth (cookie or Bearer api_tokens)
4. resolveWorkspaceContext → active organization + org role + team ids
5. requireWorkspaceWrite → 403 for org role "member" on POST/PUT/DELETE
6. Routes owned by tenant-api → signed inter-service JWT → proxied

See Authentication & Authorization for the full role model.

Better Auth validates sessions on every authenticated request. Two caching layers reduce database pressure:

ModeLayer 1Layer 2DB hit
Without RedisCookie cache (5 min, per browser)On cache miss
With RedisRedis (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.

express-rate-limit protects control-api’s /auth/* endpoints (50 req / 15 min) and tenant-api’s /import endpoint (10 req / min).

ModeStoreBehaviour with multiple replicas
Without RedisIn-memory (per process)Each replica has independent counters
With Redisrate-limit-redisCounters shared — limits enforced globally regardless of replica count

The schema is split into two logical parts.

Control-plane (schema.control.ts) — shared database

Section titled “Control-plane (schema.control.ts) — shared database”
TableDescription
organizationsOrganization entity (id, name, slug, enabled suspend flag)
teams / team_membersTeams within an organization and their membership
membersOrganization membership — userIdorganizationId, role: owner|admin|member
invitationsPending org/team invitations
usersPlatform users — username, role: user|platform_admin
sessions / accounts / verificationsBetter Auth session, credential and verification tables
api_tokensBearer tokens for REST/MCP — per user, per organization, optionally per workspace
oauth_providersConfigured OAuth/OIDC providers (admin web)
site_settingsLogin message / site-wide banner
tenant_databasesRegistry 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”
TableDescription
workspacesArchiMate models — uuid, name, description, organization_id
workspace_teamsRestricts a workspace’s visibility to specific teams
user_active_workspaceEach user’s active workspace per organization
elements / element_propertiesArchiMate elements + custom properties
relationships / relationship_propertiesArchiMate relationships + custom properties
property_definitionsCustom property schemas
views / nodes / connections / bendpointsDiagrams, 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.

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 Auth afterCreateOrganization hook — creates a dedicated Neon role + database, runs the tenant-only migrations, seeds an empty “Default” workspace, and marks tenant_databases "active".
  • Existing organizations: pnpm --filter control-api migrate-tenant <organizationId> (or --all) copies all workspaces into a newly provisioned dedicated database, then --cleanup --yes removes 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).

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.

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.

Terminal window
cd packages/db
DB_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.

Phase 1 (Demo) → Single shared Postgres + Redis, all orgs on control-plane DB
Phase 2 (MVP) → docker-compose: control-api + tenant-api + mcp-server + web + Traefik + Postgres + Redis
Phase 3 (Scale) → Multiple replicas per service behind the load balancer
Phase 4 (Isolation)→ Per-organization dedicated Neon database via tenant_databases

Adding 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).