DEV Community

Albin Manoj
Albin Manoj

Posted on

Title For Cross Platform posting

Architecture Document

White-Label Multi-Tenant

Chat Platform

5 Implementation Phases

2 Database Layers

13 Mongoose Schemas

CASL Permissions

Redis + Socket.IO

01 —

Database Architecture

🏛 PLATFORM_DB  ·  Shared

Organization auth · branding · roles · plan

SuperAdmin email · role · mfa

DomainMapping domain · orgSlug · tls

AuditLog action · actor · TTL 1yr

slug

🗃 org_{slug}_db  ·  Per Tenant

User role · externalId · CASL

Channel type · visibility · dm_key · CASL

ChannelMembership channelId · userId · unread

Message content · reactions · CASL

Notification type · userId · TTL 30d

Pin channelId · messageId

File s3Path · fileType · size

Job purge · export · status

Setting _id key · Mixed value

Isolation Strategy: database-per-tenant — no tenantId fields needed. Each org gets org_{slug}_db. Resolved via req.orgDb (Connection) injected by orgResolver middleware. Models registered via registerOrgModels(conn) / getModels(conn) factory pattern.

02 —

Implementation Phases

PHASE 01

Remove Legacy Code

  • Delete Okta / reports APIs
  • Remove 5 stale models
  • Clean User + Channel schemas
  • Remove oktaJobs, scripts
  • Strip domain-specific logic

PHASE 02

Multi-Org Backend

  • connectionManager (LRU)
  • orgResolver middleware
  • Platform DB models
  • Refactor all controllers
  • Per-org JWT + S3 + SAML

PHASE 03

Frontend White-Label

  • OrgProvider context
  • Fetch /api/v1/org/config
  • CSS variable injection
  • Dynamic logo/title/favicon
  • Auth flow per provider

PHASE 04

Super-Admin Panel

  • Separate React app
  • admin.chatapp.com
  • Org provisioning wizard
  • Branding + feature editor
  • Role management UI

PHASE 05

Infrastructure

  • Nginx wildcard TLS
  • PM2 cluster mode
  • Redis Socket.IO adapter
  • Custom domain TLS (LE)
  • ip_hash sticky sessions

03 —

Platform DB — Schema Details

Organization

no-CASL platform_db

slugString unique

nameString

statusactive | suspended | provisioning

dbNameString unique

domains[]String[]

authokta | auth0 | saml subdoc

twilioenabled · flex · sid · token

brandingappName · logo · 12 colors

storageS3 / DO Spaces per-org

emailSendGrid per-org

roles[]name · level · permissions[]

channelTypes[]key · label · icon · autoCreate

features11 boolean flags

plantier · maxUsers · maxStorageMB

SuperAdmin platform_db

emailString unique

passwordHashString

displayNameString

rolesuper_admin | platform_admin

lastLoginAtDate

mfaEnabledBoolean

mfaSecretString

DomainMapping platform_db

domainString unique

orgSlugString

verifiedBoolean

verifiedAtDate

tlsCertPathString

dnsVerificationTokenString

AuditLog

TTL 1yr platform_db

orgSlugString

actionString

actorEmailString

actorTypeuser | superadmin | system

detailsMixed

ipAddressString

timestampDate 31,536,000s TTL

04 —

Org DB — Schema Details

User

CASL org_db

displayNameString

emailString unique

roleString (validated vs org.roles)

avatarUrlString

isProtectedBoolean

statusactive | inactive | suspended

lastLoginAtDate

lastActiveAtDate

externalIdString (generic SSO)

metadataMixed (timezone, locale)

Channel

CASL org_db

nameString

typeString (from org.channelTypes)

visibilitypublic | private | dm

protectedBoolean

createdBy→ User

members[]→ User[]

dm_keyString unique-partial

publicIdString

settingsthreading · slowMode · viewOnly

origin_typesystem | tag | invite | api

ChannelMembership org_db

channelId→ Channel compound-unique

userId→ User

joinedAtDate

origin_typesystem | tag | invite | api

invited_by→ User

lastReadAtDate

unreadCountNumber

mentionCountNumber

mutedBoolean

roleString (channel-level override)

Message

CASL org_db

channelId→ Channel

senderId→ User

contentString

attachments[]Mixed[]

parentMessageId→ Message (thread)

replyCountNumber (denormalized)

reactions[]emoji · users[]

editedAtDate

isDeletedBoolean

taggedUsers[]→ User[]

richContentMixed

Notification

TTL 30d org_db

userId→ User

typemention | message | invite | reply | reaction

triggeredBy→ User

channelId→ Channel

messageId→ Message

titleString

readBoolean

expiresAtDate (pre-save hook)

Pin / File / Job / Setting org_db

Pin.channelId + messageIdunique

Pin.isGlobalPinBoolean

File.s3PathString

File.fileType / fileSizeString / Number

File.thumbnailPathString

Job.jobTypepurge | export

Job.statuspending | running | completed | failed

Job.jobDateDate partial-unique

Setting._idString (key)

Setting.valueMixed

05 —

CASL Permission System

100

Admin

  • manage all (wildcard)
  • Full platform access
  • Role assignment
  • Channel management

50

Moderator

  • Channel CRUD
  • Delete any message
  • Pin any message
  • Upload files

10

Member

★ DEFAULT ROLE

  • Read public channels
  • Join public channels
  • Own message CRUD
  • React, upload, delete own files

Condition interpolation: Permissions use {"senderId": "${user.id}"} templates — resolved at ability-build time via buildAbilityForUser(org, user). Route guard: authorize('delete', 'Channel') middleware. Mongoose: .accessibleBy(req.ability, 'read'). Frontend: via AbilityContext.

Note: Use CASL's native subject('Message', doc) pattern for own-resource checks — do not rely on string interpolation alone. Guard all .accessibleBy() calls with ability.can('read', 'Message') to prevent ForbiddenError throws.

06 —

Redis & Infrastructure

Socket.IO Redis Adapter

  • @socket.io/redis-adapter with ioredis
  • Pub/sub client pair in server.ts
  • All io.to().emit() calls unchanged
  • Rooms: ${slug}:channel:${id}
  • Nginx ip_hash for sticky sessions

Redis Keys & Cache

  • Membership: membership:{channelId} TTL 5m
  • Presence: presence:{userId} HASH + online:{slug} SET
  • Heartbeat every 30s, TTL 35s
  • Org config pub/sub: org:config:updated
  • Rate limit key: ${slug}:${ip}

PM2 + Nginx

  • PM2 cluster, instances: 2
  • increment_var: PORT
  • Nginx upstream with ip_hash
  • Wildcard TLS + custom domain LE
  • Graceful Redis degradation → DB fallback

07 —

Review Findings & Gaps

🐛 Bug

Setting schema _id conflict

_id: {type: String} + {_id: false} is version-dependent in Mongoose 7+. Must be tested explicitly.

🐛 Bug

CASL condition interpolation fragile

JSON.stringify().replace() breaks for any variable other than ${user.id}. Switch to native subject() pattern for own-resource checks.

🐛 Bug

accessibleBy() throws on no permissions

Message.find().accessibleBy(ability) throws ForbiddenError (not empty). Guard with ability.can('read','Message') before the query.

⚠ Security

Sensitive fields stored plaintext

jwtSecret, twilio.authToken, secretAccessKey, email.apiKey need encryption. Decide: Atlas FLE or app-layer encrypt-on-write before implementation — retrofitting is painful.

⚠ Security

Org config cache invalidation missing

LRU cache (5-min TTL) means org suspension or jwtSecret rotation won't propagate for up to 5 min. Add Redis pub/sub bust on org:config:updated for security-sensitive changes.

⚠ Security

SAML RelayState not implemented

Shared /api/v1/auth/saml/callback needs org slug encoded in RelayState. Some IdPs strip it — need short-lived fallback session keyed by SAML request ID.

◎ Gap

Role validation is application-only

User.role is a plain string with no DB constraint. Add Mongoose pre-save hook validating against org.roles[].name — typos silently return empty CASL ability.

◎ Gap

ChannelMembership.role has no CASL integration

Channel-level role override field exists in schema but buildAbilityForUser only reads user.role. Implement or remove the field to avoid confusion.

◎ Gap

File storage quota not enforced

plan.maxStorageMB exists but no enforcement mechanism. Choose: aggregate-on-upload (expensive) or running counter in Setting (fast). Decide before upload service.

◎ Gap

autoCreate on ChannelType is ambiguous

"Auto-create for new users" — create channel once at org provisioning, or add each new user to it? These are very different behaviors. Must be defined.

08 —

Environment Variables

BACKEND .env

MONGO_URIShared cluster URI

PLATFORM_DB_NAMEplatform_db

PORTServer port

BASE_URL / FRONTEND_URLPublic URLs

CORS_ORIGINSAllowed origins

SUPER_ADMIN_JWT_SECRETPlatform-level JWT

REDIS_HOST / PORT / PASSWORDRedis connection

REDIS_TLSTLS flag

DO_SPACE_KEY / SECDefault S3 fallback

DO_SPACE_REGION / ENDPOINTDO Spaces region

FRONTEND .env

VITE_API_BASE_URLBackend API root

VITE_SOCKET_URLSocket.IO endpoint

All tenant-specific config (branding, auth provider, features) is fetched at runtime from /api/v1/org/config — not baked into env vars. OrgProvider injects CSS variables and adapts auth flow per org.

09 —

Technology Stack

🍃MongoDB / Mongoose 7+

🔌Socket.IO v4.6+

⚡Redis (ioredis)

🔐CASL v6

🔑Auth0 / Okta / SAML

📦PM2 Cluster

🌐Nginx + Let's Encrypt

☁DigitalOcean Spaces / S3

📧SendGrid (per-org)

📞Twilio Flex (per-org)

⚛React + Vite frontend

🟦TypeScript end-to-end

WHITE-LABEL SAAS CHAT PLATFORM · ARCHITECTURE v1.0 platform_db + org_{slug}_db · 13 schemas · 5 phases

Top comments (0)