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)