NexGate Chat System — Development Architecture Guide
QBIT SPARK | NexGate Platform The complete reference for building the chat_system module inside nexgate_backend
Table of Contents
- Overview & Philosophy
- Inbox Model — Two Separate Inboxes
- The Four Tunnels — How chat_system Works
- Cards — Rich Content Type
- Content Types & User Boxes
- Where Chat Lives in the Codebase
- The Three Communication Channels
- Full System Diagram
- Authentication Flow
- Message Flow — 1:1 Chat
- Message Flow — Group Chat
- Commerce DM Flow
- Call Flow — Voice & Video
- Secret Chat — End-to-End Encryption
- NexGate Custom Namespaces
- RabbitMQ Event Pipeline
- Shop Inbox & Staff Access
- Offline & Push Notification Flow
- Multi-Device Support
- Redis — What to Cache & Why
- Best Practices & Anti-Patterns
- Package Structure
- Infrastructure Stack
- Build Order
1. Overview & Philosophy
What NexGate Chat Is
NexGate chat is NOT a standalone service.
It is a package inside nexgate_backend.
Three pillars of NexGate:
VP Feed → social content
VP Shop → commerce
VP Events → event management
Chat → the connective tissue between all three
Chat is deeply integrated with commerce.
Every conversation can become a transaction.
Every transaction has a conversation behind it.
The Key Principle
Ejabberd = the transport layer (moves stanzas)
Spring Boot = the business logic layer (decides what happens)
PostgreSQL = the persistence layer (stores everything)
RabbitMQ = the event bus (connects Ejabberd to Spring Boot)
Ejabberd knows NOTHING about:
NexGate users
Orders
Shops
Offers
Commerce
Spring Boot knows EVERYTHING about:
Who can talk to whom
What messages mean
Commerce context
Offer lifecycle
Notifications
Why Not a Separate Microservice
Chat is INSIDE nexgate_backend because:
Shares user data constantly
(display names, avatars, shop memberships)
Shares order data
(order confirmations, shipping updates)
Shares product data
(product cards, offer sessions)
No distributed transactions needed
Same team, same codebase
Simple joins instead of API calls
Extract chat service later IF:
Chat traffic overwhelms platform
Separate team manages chat
DB connections exhausted by chat
NOT before — premature optimization
2. Inbox Model — Two Separate Inboxes
The Two Inboxes
NexGate has TWO completely separate chat inboxes:
Inbox 1 — Personal Chats:
1:1 DMs with other users (@username)
Group chats (friends, community)
Accessed via: Chat → Personal tab
Inbox 2 — Commerce Chats:
DMs with shops ($tag)
Order updates from shops
Offer sessions
Group buy notifications
Accessed via: Chat → Commerce tab
Why separate?
Personal: social conversations
Commerce: transactional conversations
User never loses personal chats
in a flood of order notifications ✅
Clean mental model ✅
Like WhatsApp vs WhatsApp Business ✅
Conversation Types
DIRECT:
Between two @users
Personal 1:1 conversation
Inbox: Personal
GROUP:
Multiple @users
Created explicitly
Inbox: Personal
COMMERCE_DM:
Between @user and $shop
Initiated by either side
Contains: product cards, offers, order updates
Inbox: Commerce
COMMERCE_GROUP:
Multiple @users + commerce context
Bei ya pamoja group buying
Inbox: Commerce (for the shop)
Inbox: Personal (for the buyers — it's a group)
SYSTEM:
Auto-generated per order
No human on shop side (automated)
Order confirmation, shipping updates
Inbox: Commerce
How Conversations Are Created
DIRECT — user taps on @username → start chat:
POST /chat/conversations/direct
{ targetUserId }
Spring Boot: find existing OR create new
type = DIRECT
GROUP — user creates a group:
POST /chat/groups/create
{ name, type: PRIVATE|PUBLIC }
Spring Boot: create + Ejabberd room
type = GROUP
COMMERCE_DM — buyer taps [Chat with Shop]:
POST /chat/conversations/commerce
{ shopId }
Spring Boot: find existing OR create new
Ejabberd: uses shop JID (shop-456@nexgate.com)
type = COMMERCE_DM
COMMERCE_DM — order placed (auto):
Spring Boot order service → triggers:
CommerceDmService.getOrCreateCommerceThread(userId, shopId)
First order message auto-sent:
"Your order ORD-789 has been confirmed"
type = COMMERCE_DM
SYSTEM — one per order (auto):
Spring Boot: createSystemConversation(orderId)
Only system messages
No user can reply
type = SYSTEM
Inbox API — What Mobile Calls
Personal inbox:
GET /chat/conversations?type=personal
Returns: DIRECT + GROUP conversations
Sorted by: last_message DESC
Commerce inbox:
GET /chat/conversations?type=commerce
Returns: COMMERCE_DM + SYSTEM conversations
Sorted by: last_message DESC
Grouped by: shop (all threads with TechStore together)
Unread counts:
GET /chat/conversations/unread-counts
Returns:
{ personal: 3, commerce: 7 }
Source: Redis (never DB query)
Used for: tab badges in UI
Shop Side — Shop Inbox
Shop staff logs into NexGate shop dashboard:
Sees: ALL conversations with customers
Sorted by: unread first, then recent
Shop inbox API:
GET /chat/shop/{shopId}/conversations
Auth: requires shop staff JWT
Returns: all COMMERCE_DM for this shop
Shows: customer name, last message, unread
Staff replies:
POST /chat/conversations/{convId}/messages
{ body, attachments }
Sent as: shop-456@nexgate.com (no staff name) ✅
Shop cannot see:
Conversations between customers ❌
Personal chats ❌
Other shops' conversations ❌
JID Mapping Per Conversation Type
DIRECT (alice → juma):
Alice JID: usr-alice@nexgate.com
Juma JID: usr-juma@nexgate.com
Ejabberd: direct XMPP stanza ✅
GROUP (warriors group):
Room JID: conv-abc123@conference.nexgate.com
Members: usr-alice, usr-juma, usr-enkiama
Ejabberd: MUC room ✅
COMMERCE_DM (alice → TechStore):
Alice JID: usr-alice@nexgate.com
Shop JID: shop-456@nexgate.com
Staff: shop-456@nexgate.com/staff-1 (internal)
Customer sees: shop-456@nexgate.com ✅
SYSTEM (order notification):
From JID: system@nexgate.com
To JID: usr-alice@nexgate.com
type: headline (no offline storage trigger)
Spring Boot sends via Ejabberd REST
Conversation Routing Rules
Message arrives from: usr-juma@nexgate.com
→ Check: is this in existing DIRECT conversation?
→ Yes → route to that conversation ✅
→ No → create new DIRECT conversation ✅
→ Inbox: Personal
Message arrives from: shop-456@nexgate.com
→ Check: is this in existing COMMERCE_DM?
→ Yes → route to that conversation ✅
→ No → create new COMMERCE_DM ✅
→ Inbox: Commerce
Message arrives from: system@nexgate.com
→ Route to SYSTEM conversation for that order
→ Inbox: Commerce
Message arrives from: conv-abc123@conference.nexgate.com
→ Route to GROUP conversation ✅
→ Inbox: Personal
3. The Four Tunnels — How chat_system Works
Inspiration
Inspired by the Cu Chi tunnel system in Vietnam —
250km of specialized tunnels, each built for a
specific purpose, each carrying specific content,
all invisible to those above.
chat_system works the same way:
Four specialized tunnels
Each carries specific content type
Each powered by the right engine
All meet at the Central Station (Ejabberd)
All invisible to the network above
The Four Tunnels
┌─────────────────────────────────────────────────────────┐
│ chat_system │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ TEXT │ │ MEDIA │ │ VOICE │ │
│ │ TUNNEL │ │ TUNNEL │ │ TUNNEL │ │
│ │ │ │ │ │ │ │
│ │ Messages │ │ Images │ │ 1:1 Audio │ │
│ │ Cards │ │ Videos │ │ Group Audio│ │
│ │ Typing │ │ Voice note │ │ │ │
│ │ Receipts │ │ Documents │ │ │ │
│ │ Secret🔒 │ │ GIFs │ │ │ │
│ │ │ │ Stickers │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────────────────────────────────────────┐ │
│ │ EJABBERD (Central Station) │ │
│ │ Routes ALL tunnels to destination │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ │ ┌──────▼──────┐ │
│ │ VIDEO │ │ │ COTURN │ │
│ │ TUNNEL │ │ │ LIVEKIT │ │
│ │ │ │ │ RELAY │ │
│ │ 1:1 Video │ │ │ ENGINES │ │
│ │ Group Video│ │ │ │ │
│ │ Screen share│ │ │ │ │
│ └─────────────┘ │ └─────────────┘ │
│ FILE THUNDER │
│ processes media │
│ before delivery │
└─────────────────────────────────────────────────────────┘
Tunnel 1 — Text Tunnel
What it carries:
Plain text messages
Cards (all 6 categories)
Typing indicators
Delivery receipts (✓✓)
Read receipts (🔵)
Reactions (👍❤️😂)
Message edits + deletes
Presence (online/offline)
Commerce stanzas
Group invitations
System notifications
Secret Chat (encrypted) 🔒
Engine: Ejabberd (XMPP)
Protocol: XML stanzas over WebSocket/TCP
Port: 5222 with TLS
Security levels inside Text Tunnel:
Level 1 — Normal: stored in DB (readable)
Level 2 — Commerce: auditable, legal record
Level 3 — Secret Chat: OMEMO encrypted
nobody can read, not even NexGate
Tunnel 2 — Media Tunnel
What it carries:
Images (JPEG, PNG, WebP)
Videos (MP4, HLS)
Voice notes (OGG/Opus)
Documents (PDF, DOC etc)
GIFs (MP4 loop)
Stickers (WebP)
Files (any type)
Engines:
File Thunder → processes + stores + CDN
Ejabberd → delivers the stanza with CDN URL
Flow:
Sender uploads → File Thunder processes
File Thunder → CDN URL
Ejabberd stanza → delivers URL to recipient
Recipient → downloads from CDN directly
Ejabberd never touches the file itself
Only delivers the URL stanza ✅
Tunnel 3 — Voice Tunnel
What it carries:
1:1 voice calls
Group voice calls (3+ people)
Engines:
Ejabberd → Jingle signaling (call setup)
WebRTC → audio capture + encoding (Opus)
Coturn → relay when P2P blocked (EA NAT)
LiveKit → group calls (3+ people only)
1:1 voice:
Try P2P direct first
If blocked (carrier NAT) → Coturn relay
Engine: WebRTC + Coturn
Group voice (3+):
Always LiveKit SFU
No P2P attempt
Each person uploads 1 stream
LiveKit forwards to all others
Switching 1:1 → group mid-call:
Spring Boot creates LiveKit room
Sends join stanzas to all
Old WebRTC terminates
All join LiveKit seamlessly ✅
Tunnel 4 — Video Tunnel
What it carries:
1:1 video calls
Group video calls (3+ people)
Screen sharing
Engines:
Ejabberd → Jingle signaling
WebRTC → video capture + H.264 encoding
Coturn → relay when P2P blocked
LiveKit → group calls (3+ people only)
1:1 video:
Try WebRTC P2P first
If blocked → Coturn relay
Engine: WebRTC + Coturn
Group video (3+):
Always LiveKit SFU
Simulcast: 3 quality levels per stream
Low (180p): 2G networks
Medium (360p): 3G networks
High (720p): 4G/WiFi
Each receiver gets quality their network allows
Screen share:
Android: MediaProjection API
iOS: ReplayKit broadcast extension
H.264 encoding, 5-15fps
Works in both 1:1 and group calls
Switching 1:1 → group mid-call:
Same as Voice Tunnel upgrade flow ✅
Why Not LiveKit for Everything?
Question: why not use LiveKit for 1:1 calls too?
WebRTC P2P (1:1):
Alice → directly → Bob (no server)
Zero extra latency ✅
Zero server cost ✅
Privacy: nobody in the middle ✅
Faster connection ✅
LiveKit SFU (group):
Alice → LiveKit server → Bob
Extra server hop = more latency ❌
Server cost per call ❌
Server sees media ❌
Rule: P2P when 2 people (always faster + cheaper)
SFU when 3+ people (P2P impossible on mobile)
Why P2P fails for groups:
5 people P2P mesh:
Each uploads 4 streams
Each downloads 4 streams
= 8 streams simultaneously
= 8Mbps needed per person ❌
EA 4G average = 5Mbps ❌
Impossible on EA networks ❌
5 people via LiveKit SFU:
Each uploads 1 stream only ✅
LiveKit forwards to others
Feasible on EA 3G/4G ✅
The Engine Registry
Engine Tunnel(s) Role
──────────────────────────────────────────────────────────
Ejabberd ALL (signaling) Central Station
Routes everything
XMPP stanzas
File Thunder Media Tunnel Process + store
Virus scan
CDN delivery
WebRTC Voice + Video Audio/video capture
Encode/decode
On-device processing
Coturn Voice + Video Relay station
Bypasses EA NAT
TURN protocol
LiveKit SFU Voice + Video Group calls (3+)
(group only) Simulcast engine
One→many forwarding
OMEMO Text Tunnel Encryption engine
(Secret Chat) Keys on device only
Level 3 security
Tunnel Security Levels
Inspired by Cu Chi tunnel depth levels:
Level 1 — Surface (Normal Chat):
Regular conversations
Stored in DB (Spring Boot can read)
Used for: casual chat, group chat
Engine: Ejabberd + PostgreSQL
Level 2 — Mid-depth (Commerce DMs):
Business conversations
Stored + auditable
Legal record for disputes
Staff audit log
Used for: shop DMs, order updates
Engine: Ejabberd + PostgreSQL (audit)
Level 3 — Deep (Secret Chat):
End-to-end encrypted (OMEMO)
Encrypted blob stored in DB
NexGate CANNOT read content ✅
Keys NEVER leave device ✅
Used for: private personal conversations
Engine: OMEMO + Ejabberd (transport only)
4. Cards — Rich Content Type
What Cards Are
Cards are rich interactive content blocks
that travel inside the Text Tunnel.
Like a specialized cargo train
running inside the text tunnel:
Same tunnel (Ejabberd routes it) ✅
Same transport (XMPP stanza) ✅
Different cargo (rich structured data) ✅
Different rendering (UI card) ✅
Every card:
Has a custom xmlns (identifies it) ✅
Has a type field (what kind exactly) ✅
Has a <body> fallback (for basic clients) ✅
Has action buttons (1-3 max) ✅
Renders as visual card in ChatBox ✅
The Six Card Categories
Category 1 — Social Cards
Source: VP Feed
xmlns: urn:nexgate:feed:1
Types:
POST_CARD → feed post shared in chat
REEL_CARD → short video shared
LIVE_CARD → live stream invite
PROFILE_CARD → user profile share
Category 2 — Commerce Cards
Source: VP Shop
xmlns: urn:nexgate:commerce:1
urn:nexgate:offer:1
urn:nexgate:groupbuy:1
urn:nexgate:installment:1
Types:
PRODUCT_CARD → single product
SHOP_CARD → shop profile
FLASH_SALE_CARD → time-limited sale
CUSTOM_PRICE_OFFER → private negotiated price
GROUP_BUY_CARD → Bei ya pamoja
INSTALLMENT_PLAN → buy now pay later
Category 3 — Event Cards
Source: VP Events
xmlns: urn:nexgate:event:1
Types:
EVENT_CARD → event details
TICKET_CARD → purchased ticket
EVENT_REMINDER → reminder notification
EVENT_GROUP_BUY → group ticket purchase
Category 4 — System Cards
Source: Spring Boot (automated, no human)
xmlns: urn:nexgate:system:1
Types:
ORDER_CONFIRMATION → order placed ✅
ORDER_STATUS_UPDATE → shipped/delivered
PAYMENT_CONFIRMATION → payment received
REFUND_CARD → refund processed
DISPUTE_CARD → dispute opened
Category 5 — Group Cards
Source: chat_system
xmlns: urn:nexgate:group:1
urn:nexgate:call:1
Types:
GROUP_INVITATION → join group request
GROUP_CALL_JOIN_INFO → join ongoing call
CALL_INITIATED → incoming call
CALL_DECLINED → call declined
CALL_MISSED → missed call
CALL_ENDED → call summary
HOST_MUTED_YOU → muted by host
HOST_REMOVED_YOU → removed by host
PARTICIPANT_ADDED → someone joined call
Category 6 — API Template Cards (Future)
Source: Third-party developers via API
xmlns: urn:nexgate:template:1
Types:
TRANSACTIONAL → order/delivery updates
MARKETING → promotions (opt-in only)
AUTHENTICATION → OTP, verification
Card Anatomy
Every card follows this structure:
┌─────────────────────────────────────┐
│ HEADER │
│ Category icon + source label │
│ e.g. 🛍️ VP Shop │
│─────────────────────────────────────│
│ BODY │
│ Card-specific content │
│ Image, title, price, progress etc │
│─────────────────────────────────────│
│ FOOTER │
│ Action buttons (1-3 max) │
│ Status indicator │
└─────────────────────────────────────┘
Example — Product Card:
┌─────────────────────────────────────┐
│ 🛍️ VP Shop │
│─────────────────────────────────────│
│ [product image] │
│ Samsung A15 │
│ TZS 450,000 · TechStore │
│ Stock: 12 units │
│─────────────────────────────────────│
│ [View Product] [Chat with Shop] │
└─────────────────────────────────────┘
Example — Order Confirmation:
┌─────────────────────────────────────┐
│ ⚙️ Order Update │
│─────────────────────────────────────│
│ ✅ Order Confirmed │
│ ORD-789 · Samsung A15 │
│ TZS 400,000 · M-PESA │
│─────────────────────────────────────│
│ [Track Order] │
└─────────────────────────────────────┘
Example — Group Buy:
┌─────────────────────────────────────┐
│ 👥 Group Purchase │
│─────────────────────────────────────│
│ Samsung A15 │
│ Public: TZS 450,000 │
│ Group: TZS 350,000 (10 people) │
│ ████████░░ 8 / 10 joined │
│ Expires: 2h 30m │
│─────────────────────────────────────│
│ [Join Group Buy] │
└─────────────────────────────────────┘
Card Stanza Structure
<!-- Every card follows this pattern -->
<message to="bob@nexgate.com"
type="chat"
id="card-001">
<body>Check out this product</body>
<!-- ↑ ALWAYS include fallback body
Basic clients see this text
NexGate app renders the card -->
<nexgate-commerce xmlns="urn:nexgate:commerce:1">
<type>PRODUCT_CARD</type>
<!-- type field tells app HOW to render -->
<!-- card-specific data below -->
<product>
<id>prod-123</id>
<name>Samsung A15</name>
<price>450000</price>
<currency>TZS</currency>
<image_url>https://cdn.nexgate.com/img.jpg</image_url>
<shop_name>TechStore</shop_name>
<shop_id>shop-456</shop_id>
<stock>12</stock>
<snapshot_at>2026-07-17T10:00:00Z</snapshot_at>
</product>
</nexgate-commerce>
</message>
Mobile Card Decision Tree
Message arrives → check xmlns:
urn:nexgate:feed:1 ?
POST_CARD → render post preview + [View Post]
LIVE_CARD → render live badge + [Watch Live]
urn:nexgate:commerce:1 ?
PRODUCT_CARD → render product + [View] [Chat]
SHOP_CARD → render shop + [Visit Shop]
urn:nexgate:offer:1 ?
PENDING → render offer + timer + [Accept] [Decline]
ACCEPTED → "Offer Accepted ✅"
EXPIRED → "Offer Expired ⏰"
urn:nexgate:groupbuy:1 ?
GROUP_BUY_CARD → render progress bar + [Join]
urn:nexgate:event:1 ?
EVENT_CARD → render event + [View] [Get Ticket]
TICKET_CARD → render ticket QR + [View]
urn:nexgate:system:1 ?
ORDER_CONFIRMATION → render order + [Track]
ORDER_STATUS_UPDATE → render status badge
PAYMENT_CONFIRMATION → render payment details
urn:nexgate:call:1 ?
CALL_INITIATED → show incoming call screen
GROUP_CALL_JOIN_INFO → [Join Call] [Decline]
CALL_MISSED → "Missed call from Alice" 📞
urn:nexgate:group:1 ?
GROUP_INVITATION → [Accept] [Decline]
urn:nexgate:template:1 ?
TRANSACTIONAL → render template card
MARKETING → render with unsubscribe option
No xmlns match ?
→ render as plain text (use <body>) ✅
5. Content Types & User Boxes
The Five Content Types
chat_system delivers exactly 5 content types:
Type 1 — Text Plain
What: pure text messages
Tunnel: Text Tunnel
Engine: Ejabberd
Example: "Hello Juma!"
Type 2 — Cards
What: rich interactive content blocks
Tunnel: Text Tunnel (same engine)
Engine: Ejabberd
6 categories: Social, Commerce, Event,
System, Group, Template
Example: Product card, Order update
Type 3 — Media Bundle
What: files users share
Tunnel: Media Tunnel
Engine: File Thunder + Ejabberd
Types: Image, Video, Voice note,
Document, GIF, Sticker
Type 4 — Audio Stream
What: real-time voice
Tunnel: Voice Tunnel
Engine: WebRTC + Coturn (1:1)
LiveKit SFU (group)
Includes: 1:1 voice, group voice
Type 5 — Video Stream
What: real-time video
Tunnel: Video Tunnel
Engine: WebRTC + Coturn (1:1)
LiveKit SFU (group)
Includes: 1:1 video, group video, screen share
Two User-Facing Boxes
ChatBox:
What it shows: conversation thread
Delivers:
Type 1: Text Plain → message bubbles
Type 2: Cards → rich interactive blocks
Type 3: Media Bundle → image/video/voice player
CallBox:
What it shows: call screen
Delivers:
Type 4: Audio Stream → voice call UI
Type 5: Video Stream → video call UI
1:1 CallBox:
Two participants
Microphone, camera, hang up controls
Group CallBox:
Grid of participant tiles
Raise hand ✋
Emoji reactions 😂❤️🎉
Host controls (mute, remove)
Pin participant
Speaker/grid layout switch
Call Actions — Who Handles What
Action Handler
──────────────────────────────────────────────────────
Mute/unmute mic Local WebRTC (device)
Camera on/off Local WebRTC (device)
Switch front/back cam Local WebRTC (device)
Speaker/earpiece Local OS audio
Hang up Jingle session-terminate
Raise hand ✋ LiveKit metadata broadcast
Lower hand LiveKit metadata broadcast
Emoji reaction 😂 LiveKit sendData() broadcast
Pin participant Local UI only (no server)
Switch grid/speaker Local UI only (no server)
Disable incoming video Local WebRTC (save data)
Mute participant (host) LiveKit SDK + Spring Boot auth
Remove participant LiveKit SDK + Spring Boot auth
Add participant mid-call Spring Boot (new LiveKit room)
→ sends join stanzas to all
→ old WebRTC terminates
→ all migrate to LiveKit
Audio → Video switch Jingle content-add
Video → Audio switch Jingle content-remove
Screen share start Jingle content-add (screen)
Screen share stop Jingle content-remove (screen)
1:1 → Group Call Upgrade Flow
Alice + Bob in 1:1 WebRTC call
Alice taps "Add Participant" → selects Juma
Spring Boot:
1. Creates new LiveKit room
2. Generates tokens for Alice, Bob, Juma
3. Sends stanza to Alice:
<nexgate-call xmlns="urn:nexgate:call:1">
<type>GROUP_CALL_JOIN_INFO</type>
<reason>UPGRADED_FROM_P2P</reason>
<livekit_token>eyJ-alice...</livekit_token>
</nexgate-call>
4. Sends same to Bob (his token)
5. Sends incoming call to Juma (his token)
Alice + Bob apps:
Receive stanza
Terminate WebRTC P2P
Connect to LiveKit room
Seamless — no hang up needed ✅
Juma's app:
Incoming group call notification
[Join] button
Joins LiveKit room ✅
Result: 3-way group call on LiveKit ✅
6. Where Chat Lives in the Codebase
Package Structure
com.nexgate.backend
├── auth/ ← JWT, XMPP token issuance
├── feed/ ← VP Feed
├── shop/ ← VP Shop
├── events/ ← VP Events
├── notification/ ← FCM, Textfy SMS
│
└── chat/ ← EVERYTHING CHAT RELATED
├── config/ ← Ejabberd config, RabbitMQ config
├── controller/ ← REST endpoints for mobile
├── service/ ← Business logic
├── consumer/ ← RabbitMQ event consumers
├── ejabberd/ ← Ejabberd REST client + auth bridge
├── stanza/ ← Custom XMPP stanza builders
├── commerce/ ← Commerce DM flows
├── call/ ← Call management (TURN credentials)
└── model/ ← Chat domain models (NO DB entities here)
Database
nexgate_postgres (port 5432)
├── schema: core ← nexgate_backend owns
│ users, shops, products, orders
│
└── schema: chat ← chat package owns
conversations
messages
message_reactions
message_receipts
calls
offer_sessions
group_invite_links
notification_log
7. The Three Communication Channels
Channel 1 — Spring Boot → Ejabberd (REST API)
Direction: Spring Boot calls Ejabberd
Protocol: HTTP REST (port 5280)
Auth: Admin credentials (stored in HashiCorp Vault)
Used for: Sending stanzas, creating rooms, managing members
Examples:
Send product card to buyer
Create group room on event ticket purchase
Kick user from shop group
Send order confirmation stanza
Flow:
Spring Boot
→ POST http://ejabberd:5280/api/send_message
→ Authorization: Basic admin@nexgate.com:adminpass
→ Body: { from, to, body + custom stanza }
→ Ejabberd routes to recipient ✅
Channel 2 — Ejabberd → Spring Boot (RabbitMQ)
Direction: Ejabberd fires events → Spring Boot consumes
Protocol: RabbitMQ AMQP
Auth: RabbitMQ credentials (stored in Vault)
Used for: Knowing when messages arrive, presence changes, calls
Examples:
Message delivered → Spring Boot persists to DB
User goes offline → Spring Boot updates last seen
Call started → Spring Boot creates call record
Message read → Spring Boot updates receipt status
Flow:
Ejabberd (mod_rabbitmq)
→ publishes to exchange: nexgate.chat
→ routing key: chat.message.inbound
→ Spring Boot consumer picks up
→ persists + triggers notifications ✅
Channel 3 — Mobile → Ejabberd → Spring Boot (JWT)
Direction: Mobile connects to Ejabberd
Ejabberd validates via Spring Boot JWT
Protocol: XMPP over WebSocket/TCP (port 5222) with TLS
Auth: JWT token issued by Spring Boot on login
Used for: User XMPP sessions (all chat activity)
Flow:
1. Mobile → POST /auth/login (Spring Boot)
Spring Boot validates credentials
Issues TWO tokens:
REST JWT → for API calls (7 days)
XMPP JWT → for Ejabberd (24 hours)
2. Mobile → Ejabberd port 5222
Username: usr-kibuti@nexgate.com
Password: <XMPP JWT>
3. Ejabberd validates JWT locally
Uses public key from JWKS endpoint:
GET https://api.nexgate.com/auth/.well-known/jwks.json
Verifies signature + expiry
Allows connection ✅
4. User is in — can send/receive stanzas
8. Full System Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ NexGate Platform │
│ │
│ ┌──────────────┐ ┌─────────────────────────────────────┐ │
│ │ Mobile App │ │ nexgate_backend │ │
│ │ (Android/ │ │ │ │
│ │ iOS) │ │ ┌─────────┐ ┌─────────────────┐ │ │
│ │ │◀────────┼──│ auth/ │ │ chat/ │ │ │
│ │ Smack (XMPP)│ REST │ │ JWT │ │ │ │ │
│ │ OkHttp(REST)│ API │ │ XMPP │ │ controller/ │ │ │
│ │ │ │ │ token │ │ service/ │ │ │
│ └──────┬───────┘ │ └─────────┘ │ consumer/ │ │ │
│ │ │ │ ejabberd/ │ │ │
│ │ XMPP+TLS │ │ stanza/ │ │ │
│ │ port 5222 │ │ commerce/ │ │ │
│ │ │ │ call/ │ │ │
│ ▼ │ └────────┬────────┘ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Ejabberd │ │ Channel 1│ REST API │ │
│ │ Cluster │◀────────┼────────────────────────┘ │ │
│ │ │ Channel1│ │ │
│ │ Node 1 │ │ ┌──────────────────────────────┐ │ │
│ │ Node 2 │─────────┼─▶│ RabbitMQ │ │ │
│ │ │ Channel2│ │ exchange: nexgate.chat │ │ │
│ │ JWT auth │ │ │ queues: │ │ │
│ │ via JWKS │ │ │ chat.message.inbound │ │ │
│ └──────────────┘ │ │ chat.presence │ │ │
│ │ │ │ chat.call │ │ │
│ │ │ └──────────────┬───────────────┘ │ │
│ │ │ │ Channel 2 │ │
│ │ │ ▼ │ │
│ │ │ chat/consumer/ │ │
│ │ │ persists messages │ │
│ │ │ triggers FCM │ │
│ │ │ updates receipts │ │
│ │ └─────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────────┐ │
│ │ │ nexgate_postgres:5432 │ │
│ │ │ schema: core │ schema: chat │ │
│ │ │ users │ conversations │ │
│ │ │ shops │ messages │ │
│ │ │ products │ reactions │ │
│ │ │ orders │ receipts │ │
│ │ │ │ calls │ │
│ │ └─────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │
│ └────────────────▶│ Coturn │ │ LiveKit │ │
│ WebRTC media │ STUN/TURN │ │ Group Calls + │ │
│ (calls) │ port 3478 │ │ Audio Spaces │ │
│ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
9. Authentication Flow
Login + XMPP Token Issuance
Mobile App Spring Boot Ejabberd
│ │ │
│── POST /auth/login ─────▶│ │
│ { phone, password } │ │
│ │── validate BCrypt │
│ │── check not suspended │
│ │── generate REST JWT │
│ │── generate XMPP JWT │
│ │ payload: │
│ │ { │
│ │ jid: "usr-kibuti │
│ │ @nexgate.com",│
│ │ exp: 1753228800 │
│ │ } │
│ │ signed: RS256 │
│ │ private key (Vault) │
│◀─ { restJwt, xmppJwt } ─│ │
│ │ │
│── XMPP connect ─────────────────────────────────▶│
│ username: usr-kibuti@nexgate.com │
│ password: <xmppJwt> │
│ │ │
│ │◀── GET /auth/.well- │
│ │ known/jwks.json │
│ │ (public key) │
│ │─── { keys: [...] } ──▶│
│ │ │── verify JWT
│ │ │── check expiry
│ │ │── check jid
│◀─────── session opened ─────────────────────────│
│ │ │
JWT Key Management (Multi-Node)
Spring Boot:
Private key → stored in HashiCorp Vault
Used to SIGN XMPP JWTs
Never shared
Ejabberd (all nodes):
Public key → fetched from JWKS endpoint
GET https://api.nexgate.com/auth/.well-known/jwks.json
Used to VERIFY JWTs
All nodes fetch same endpoint ✅
Key rotation:
Spring Boot generates new key pair
Adds both old + new to JWKS endpoint
Issues new tokens with new key
Old tokens valid until expiry (24h)
After 24h → remove old key
Zero downtime rotation ✅
10. Message Flow — 1:1 Chat
Mobile Sends Message
Mobile (Alice) Ejabberd Spring Boot
│ │ │
│── XMPP stanza ───────▶│ │
│ <message │ │
│ to="bob@nexgate.com"│ │
│ type="chat" │ │
│ id="msg-001"> │ │
│ <body>Hello</body> │ │
│ <origin-id │ │
│ xmlns="urn:xmpp:sid:0" │
│ id="msg-001"/> │ │
│ <request │ │
│ xmlns="urn:xmpp:receipts"/> │
│ <markable │ │
│ xmlns="urn:xmpp:chat-markers:0"/> │
│ </message> │ │
│ │ │
│ │── route to Bob ───────▶│(if offline)
│ │ (if online → deliver directly)
│ │ │
│ │── RabbitMQ event ─────▶│
│ │ { │
│ │ event: "message", │
│ │ from: "alice", │
│ │ to: "bob", │
│ │ stanza_id: "...", │
│ │ body: "Hello", │
│ │ timestamp: ... │
│ │ } │
│ │ │── persist to DB
│ │ │── if Bob offline:
│ │ │ send FCM
│ │ │
│◀── stream ACK ────────│ │
│ <a h="N" │ │
│ xmlns="urn:xmpp:sm:3"/> │
What Spring Boot Does With Event
RabbitMQ consumer receives:
1. Find or create conversation record
2. Insert message into chat.messages
3. Detect message type:
- plain text → just persist
- commerce stanza → trigger offer flow
- system stanza → update order record
4. Check if recipient online:
- Online → delivery handled by Ejabberd
- Offline → send FCM via notification/
5. Update conversation.last_message
6. Update conversation.updated_at
Message Interactions (Stanza Reference)
Edit (XEP-0308):
<message id="edit-002">
<body>corrected text</body>
<replace xmlns="urn:xmpp:message-correct:0"
id="original-msg-id"/>
</message>
Spring Boot: update messages.body, set edited_at
Delete (XEP-0424):
<message id="retract-001">
<apply-to xmlns="urn:xmpp:fasten:0"
id="original-msg-id">
<retract xmlns="urn:xmpp:message-retract:0"/>
</apply-to>
<body>/me retracted a message</body>
</message>
Spring Boot: set messages.deleted_at, body = null
React (XEP-0444):
<message>
<reactions xmlns="urn:xmpp:reactions:0"
id="original-msg-id">
<reaction>👍</reaction>
</reactions>
</message>
Spring Boot: upsert message_reactions
Reply (XEP-0461):
<message>
<body>Thanks!</body>
<reply xmlns="urn:xmpp:reply:0"
to="alice@nexgate.com"
id="original-msg-id"/>
</message>
Spring Boot: set messages.reply_to_id
Forward (XEP-0297):
<message>
<body>Check this</body>
<forwarded xmlns="urn:xmpp:forward:0">
<message from="..." to="...">
<body>original message</body>
</message>
</forwarded>
<nexgate-forward xmlns="urn:nexgate:forward">
<original_sender_name>Juma</original_sender_name>
<forward_chain>1</forward_chain>
</nexgate-forward>
</message>
11. Message Flow — Group Chat
Key Differences From 1:1
1:1 chat: Group chat:
to = person JID to = room JID
type = "chat" type = "groupchat"
stanza-id by client stanza-id by ROOM
receipts per person no per-person receipts
reactions ref origin-id reactions ref stanza-id
Group JID format:
{conversationId}@conference.nexgate.com
e.g. conv-abc123@conference.nexgate.com
Group Creation Flow
Mobile creates group:
POST /chat/groups/create
{ name, type: PRIVATE|PUBLIC, description }
Spring Boot:
1. Create conversation record (type=GROUP)
2. Call Ejabberd REST:
POST /api/create_room
{
name: "conv-abc123",
service: "conference.nexgate.com",
options: { persistent: true, members_only: true }
}
3. Creator auto-joined as OWNER
4. Generate invite link token
5. Return { conversationId, inviteLink }
Group Join — Two Mechanisms
Mechanism 1: Consent DM Invitation
Admin selects contacts/followers
Spring Boot sends invitation stanza via Ejabberd:
<message to="juma@nexgate.com">
<nexgate-group-invite xmlns="urn:nexgate:group:1">
<group_id>conv-abc123</group_id>
<group_name>Business Friends</group_name>
<invited_by>Kibuti Mwangi</invited_by>
<expires_at>2026-07-17T10:00:00Z</expires_at>
</nexgate-group-invite>
</message>
Recipient taps Accept:
POST /chat/groups/conv-abc123/join
Spring Boot: ejabberd REST → add_member
48h no response: auto-declined silently
Mechanism 2: Invite Link
PRIVATE group: link → request → admin approves
PUBLIC group: link → instant join
Admin controls: expiry, max joins, revoke
Fan-out
Group message fan-out:
Member sends to room JID
Ejabberd MUC broadcasts to ALL members
Erlang handles fan-out natively
No Redis pub/sub needed
Spring Boot persists via RabbitMQ event
At 500 members:
Still Ejabberd MUC fan-out ✅
Erlang is built for this
12. Commerce DM Flow
Product Card (Seller Attaches in 1:1)
Seller taps [From My Shop] in 1:1 DM attach menu:
Spring Boot builds stanza:
<message to="buyer@nexgate.com" type="chat">
<body>Check out this product</body>
<nexgate-commerce xmlns="urn:nexgate:commerce:1">
<type>PRODUCT_CARD</type>
<product>
<id>prod-123</id>
<name>Samsung A15</name>
<price>450000</price>
<currency>TZS</currency>
<image_url>https://cdn.nexgate.com/img.jpg</image_url>
<shop_name>TechStore</shop_name>
<shop_id>shop-456</shop_id>
<stock>12</stock>
<snapshot_at>2026-07-17T10:00:00Z</snapshot_at>
</product>
</nexgate-commerce>
</message>
Rules:
1:1 DM only (never group) ✅
Price = current price at snapshot time
Buyer's app renders product card UI
[View Product] [Chat with Shop] buttons
Custom Price Offer Lifecycle
States: PENDING → ACCEPTED|DECLINED|EXPIRED|COMPLETED
Seller creates offer (1:1 DM only):
POST /chat/commerce/offer/create
{ conversationId, productId, offerPrice, validMinutes: 30 }
Spring Boot:
1. Create offer_sessions record
2. Build stanza:
<nexgate-offer xmlns="urn:nexgate:offer:1">
<type>CUSTOM_PRICE_OFFER</type>
<offer_id>offer-abc</offer_id>
<status>PENDING</status>
<valid_minutes>30</valid_minutes>
<pricing>
<public_price>450000</public_price>
<offer_price>400000</offer_price>
<currency>TZS</currency>
</pricing>
</nexgate-offer>
3. Send via Ejabberd REST
4. Start expiry timer (Redis)
Buyer accepts:
POST /chat/commerce/offer/accept
Spring Boot: update status → ACCEPTED
Trigger checkout flow
Offer expires (30 min):
Spring Boot scheduler fires
Update status → EXPIRED
Send expiry stanza to conversation:
<nexgate-offer xmlns="urn:nexgate:offer:1">
<type>OFFER_EXPIRED</type>
<offer_id>offer-abc</offer_id>
</nexgate-offer>
Buyer's UI: "Offer Expired ⏰"
Bei Ya Pamoja (Group Buy)
Lives in: GROUP CHAT (not 1:1)
Stanza:
<nexgate-groupbuy xmlns="urn:nexgate:groupbuy:1">
<type>GROUP_BUY_CARD</type>
<group_buy_id>gb-xyz</group_buy_id>
<product>...</product>
<pricing>
<public_price>450000</public_price>
<group_price>350000</group_price>
<currency>TZS</currency>
</pricing>
<progress>
<current>7</current>
<target>10</target>
</progress>
<expires_at>2026-07-17T18:00:00Z</expires_at>
</nexgate-groupbuy>
Spring Boot updates progress in real-time:
When member joins → send updated card stanza
When target reached → trigger group checkout
When expired → send expired card stanza
Order Updates (System Messages)
Auto-sent by Spring Boot (no manual action):
Order confirmed:
<nexgate-system xmlns="urn:nexgate:system:1">
<type>ORDER_CONFIRMATION</type>
<order_id>ord-789</order_id>
<status>CONFIRMED</status>
</nexgate-system>
Order shipped:
<nexgate-system xmlns="urn:nexgate:system:1">
<type>ORDER_STATUS_UPDATE</type>
<order_id>ord-789</order_id>
<status>SHIPPED</status>
</nexgate-system>
Rules:
Goes directly to commerce thread ✅
Cannot be deleted ✅
Cannot be edited ✅
Cannot be reacted to ✅
Immutable for legal/audit ✅
13. Call Flow — Voice & Video
1:1 Call (WebRTC + Jingle)
Alice calls Bob:
Mobile (Alice) Spring Boot Ejabberd Bob's Device
│ │ │ │
│── GET /chat/calls/ │ │ │
│ turn-credentials ──▶│ │ │
│ │── generate HMAC │ │
│ │ TURN credentials │ │
│◀── { urls, username, │ │ │
│ credential } │ │ │
│ │ │ │
│── Jingle │ │ │
│ session-initiate ────────────────────────▶│ │
│ <iq type="set"> │ │── route ───────▶│
│ <jingle │ │ │
│ action= │ │ │
│ "session-initiate"│ │ │
│ sid="call-abc"> │ │ │
│ <content │ │ │
│ name="audio"> │ │ │
│ <description │ │ │
│ media="audio">│ │ │
│ <payload-type│ │ │
│ name="opus"/>│ │ │
│ </description> │ │ │
│ <transport │ │ │
│ xmlns= │ │ │
│ "urn:xmpp: │ │ │
│ jingle: │ │ │
│ transports: │ │ │
│ ice-udp:1"> │ │ │
│ <candidate │ │ │
│ type="relay"│ │ │
│ ip="turn. │ │ │
│ nexgate.com"│ │ │
│ port=3478/>│ │ │
│ </transport> │ │ │
│ </content> │ │ │
│ </jingle> │ │ │
│ </iq> │ │ │
│ │ │ │
│ │ │◀─ session- ─────│
│ │ │ accept │
│◀── session-accept ─────────────────────────│ │
│ │ │ │
│◀═══ WebRTC Audio/Video via Coturn ══════════════════════════▶│
│ │ │ │
│── session-terminate ──────────────────────▶│ │
│ │ │ │
│── POST /chat/calls/ │ │ │
│ {callId}/end ───────▶│ │ │
│ │── update call record│ │
Audio ↔ Video Switching (No Hang Up)
Mid-call: Alice enables camera:
Alice → Jingle content-add:
<jingle action="content-add" sid="call-abc">
<content name="video">
<description media="video">
<payload-type name="H264"/>
</description>
</content>
</jingle>
Bob accepts:
Jingle content-accept
Video starts — audio uninterrupted ✅
Switch back (video → audio):
Jingle content-remove
Audio continues ✅
TURN Credentials Generation
Spring Boot generates time-limited TURN credentials:
username = timestamp:userId
credential = HMAC-SHA256(sharedSecret, username)
valid for: 1 hour
TURN server (Coturn) validates:
Checks HMAC signature
Checks timestamp not expired
Allows relay ✅
Coturn config:
use-auth-secret
static-auth-secret = <shared-secret-from-vault>
realm = nexgate.com
Group Call (LiveKit SFU)
3+ people call → use LiveKit (not P2P):
Spring Boot:
1. Create LiveKit room: group-call-{callId}
2. Generate token per participant
3. Send join info via XMPP stanza:
<nexgate-call xmlns="urn:nexgate:call:1">
<type>GROUP_CALL_JOIN_INFO</type>
<call_id>call-xyz</call_id>
<livekit_url>wss://livekit.nexgate.com</livekit_url>
<livekit_token>eyJ...</livekit_token>
<room_id>group-call-xyz</room_id>
</nexgate-call>
Each participant:
Receives stanza → phone rings
Joins → WebRTC to LiveKit
LiveKit SFU forwards streams
EA network strategy:
Simulcast (low/medium/high quality)
Each receiver gets quality network allows
Bad network → low quality (not dropped)
14. Secret Chat — End-to-End Encryption
What Secret Chat Is
Normal chat in NexGate:
Alice sends message → Ejabberd routes → Bob
Spring Boot stores message in DB (readable)
NexGate can read messages if legally required
Good for: commerce DMs, groups, order updates
Secret Chat:
Alice encrypts ON DEVICE → Ejabberd routes → Bob decrypts ON DEVICE
Spring Boot stores ENCRYPTED BLOB (unreadable)
NexGate CANNOT read messages
Keys NEVER leave the device
Good for: personal private conversations
Secret Chat = optional feature
User explicitly chooses to start one
Cannot be forced or auto-converted
Technology — OMEMO (XEP-0384)
OMEMO = Encryption that scales to multiple devices
Based on Signal Protocol (same as WhatsApp)
Built for XMPP natively
Why OMEMO over other options:
Signal Protocol = most secure available ✅
Multi-device support built-in ✅
Standard XEP = Ejabberd supports natively ✅
Forward secrecy ✅
(old messages safe even if key compromised)
Deniability ✅
(cannot prove who sent a message)
Open standard ✅ (auditable)
How OMEMO Works (Simple)
Key Setup (happens once per device):
Alice's phone generates:
Identity Key pair (permanent)
Signed PreKey pair (rotates every week)
One-Time PreKeys (100 per batch)
Alice publishes PUBLIC keys to Ejabberd:
Via XMPP PubSub (XEP-0060)
Ejabberd stores: alice's public keys
Bob can fetch them anytime
Private keys:
NEVER leave Alice's device ✅
Spring Boot NEVER sees them ✅
Ejabberd NEVER sees them ✅
Message Encryption:
Alice wants to send to Bob:
1. Fetch Bob's public keys from Ejabberd
2. Generate session key (Diffie-Hellman)
3. Encrypt message with session key
4. Encrypt session key with Bob's identity key
5. Send encrypted blob via XMPP stanza
Bob receives:
1. Decrypt session key using his private key
2. Decrypt message using session key
3. Display plaintext to Bob ✅
Ejabberd sees: encrypted blob only ❌
Spring Boot stores: encrypted blob only ❌
Nobody between Alice and Bob can read ✅
Multi-Device in Secret Chat
Alice has phone + tablet:
OMEMO encrypts for EACH device separately
Message encrypted for:
Bob's phone key ✅
Bob's tablet key ✅
Alice's tablet key ✅ (carbon copy)
Each device decrypts its own copy
All devices see the message ✅
If Alice adds new device:
Must re-establish sessions
Old messages NOT automatically available
(forward secrecy — by design) ✅
Secret Chat Flow
Alice starts Secret Chat with Bob:
Alice:
Tap Bob's profile
→ "Start Secret Chat" 🔒
→ App checks: do I have Bob's OMEMO keys?
→ If no: fetch from Ejabberd PubSub
→ Establish encrypted session
→ UI shows: "🔒 Secret Chat"
→ "Messages are end-to-end encrypted"
Alice sends message:
App encrypts locally ✅
Sends encrypted stanza:
<message to="bob@nexgate.com" type="chat">
<body>I can't read this</body>
← fallback for non-OMEMO clients
<encrypted xmlns="eu.siacs.conversations.axolotl">
<header sid="473229364">
<key rid="987654321">
BASE64_ENCRYPTED_SESSION_KEY_FOR_BOB
</key>
<key rid="111111111">
BASE64_ENCRYPTED_SESSION_KEY_FOR_ALICE_TABLET
</key>
<iv>BASE64_IV</iv>
</header>
<payload>BASE64_ENCRYPTED_MESSAGE_BODY</payload>
</encrypted>
</message>
Ejabberd:
Routes stanza (cannot read payload) ✅
Spring Boot (RabbitMQ consumer):
Receives event
Detects: has OMEMO encryption ✅
Stores encrypted payload in DB:
messages.body = null
messages.encrypted_payload = BASE64_BLOB
messages.is_e2ee = true
Does NOT attempt to decrypt ✅
Bob's device:
Receives stanza
Decrypts using private key
Displays plaintext ✅
What Spring Boot Stores vs Doesn't
Normal message:
messages.body = "Hello Bob!" ← readable
messages.is_e2ee = false
Secret Chat message:
messages.body = null ← empty ✅
messages.encrypted_payload = "BASE64..." ← blob
messages.is_e2ee = true
messages.sender_id = usr-alice (known)
messages.conversation_id = conv-abc (known)
messages.created_at = timestamp (known)
Spring Boot knows:
WHO sent ✅ (metadata)
WHEN sent ✅ (metadata)
TO WHOM ✅ (metadata)
WHAT conversation ✅ (metadata)
Spring Boot does NOT know:
WHAT was said ❌ (content encrypted)
This is by design ✅
Legal compliance: "we cannot read it" ✅
Secret Chat Rules
Can do in Secret Chat:
✅ Send text messages (encrypted)
✅ Send images (encrypted)
✅ Send voice notes (encrypted)
✅ Send files (encrypted)
✅ Set self-destruct timer
✅ Verify contact's identity (key fingerprint)
Cannot do in Secret Chat:
❌ Forward messages (breaks E2EE chain)
❌ Screenshot (Android FLAG_SECURE)
❌ Quote/reply across devices
❌ Search message content (encrypted in DB)
❌ Commerce stanzas (offer sessions need DB)
❌ Group Secret Chat (Phase 3 — complex)
❌ Web client (keys on device only)
Self-Destruct Timer
Optional feature in Secret Chat:
Alice sets: "Delete after 5 minutes"
Both devices delete locally after timer
Server record remains (encrypted blob)
BUT: server also deletes after timer ✅
Implementation:
<encrypted xmlns="eu.siacs.conversations.axolotl">
...
<nexgate-secret xmlns="urn:nexgate:secret:1">
<destruct_after_seconds>300</destruct_after_seconds>
</nexgate-secret>
</encrypted>
Spring Boot:
Receives event → sets Redis TTL on message:
EXPIRE msg:msg-abc 300
After 300 seconds:
Spring Boot deletes from DB ✅
Recipient device:
Timer shown in UI (countdown)
Local delete after timer ✅
Identity Verification
Users can verify each other's identity:
Compare OMEMO key fingerprints
Out-of-band (meet in person, voice call)
"My fingerprint: A1B2 C3D4 E5F6..."
"Your fingerprint matches ✅"
In UI:
Contact profile → "Verify Security"
Shows: "Your Safety Number with Alice"
64-character fingerprint
QR code option ✅
If verified: show verified badge 🛡️
Why this matters:
Protects against man-in-the-middle attacks
"Is Ejabberd showing me Alice's real keys?"
After verification: guaranteed ✅
Key Management in Spring Boot
Spring Boot role in OMEMO:
Store public keys (not private) ✅
Serve public keys via Ejabberd PubSub ✅
Never store private keys ❌
Public key storage:
Ejabberd PubSub handles automatically
Node: eu.siacs.conversations.axolotl.bundles:{deviceId}
Spring Boot does NOT manage this
Ejabberd handles key distribution ✅
Key rotation:
Device rotates Signed PreKey weekly
Device publishes new PreKey to Ejabberd PubSub
Spring Boot uninvolved ✅
One-Time PreKeys replenished automatically
When running low: device publishes more ✅
Spring Boot only knows:
Which users have OMEMO enabled
Which conversations are Secret Chats
That encrypted blobs exist (not content)
UI Treatment
Secret Chat conversation:
🔒 Lock icon in conversation header
Dark/different color scheme (optional)
"Messages are end-to-end encrypted" banner
Timer icon if self-destruct enabled ⏱️
Starting Secret Chat:
Long press on contact OR
Three dot menu → "Start Secret Chat"
Separate conversation from normal chat
Cannot accidentally send to wrong chat
Incoming Secret Chat:
"Alice wants to start a Secret Chat"
[Accept] [Decline]
Key fingerprint screen:
Settings → Conversations → [name]
→ "View Security Code"
Shows QR + text fingerprint
What's NOT E2EE (Important)
Secret Chat is 1:1 ONLY at launch:
❌ Group Secret Chat (Phase 3)
❌ Commerce DMs (need server-side logic)
❌ Shop conversations (staff audit needed)
❌ System messages (order notifications)
❌ Call content (WebRTC handles separately)
Call security (separate from OMEMO):
WebRTC calls: DTLS-SRTP encrypted ✅
(built into WebRTC standard)
Keys negotiated per call
Nobody can intercept call media ✅
Call metadata: Spring Boot knows
Who called whom ✅ (metadata)
How long ✅ (metadata)
But not WHAT was said ✅
Phase Roadmap for Secret Chat
Phase 1 (Launch):
✅ 1:1 Secret Chat (OMEMO)
✅ Self-destruct timer
✅ Identity verification
✅ Image/file encryption
✅ Voice note encryption
Phase 2:
⬜ Secret Chat on web (harder — keys on device)
⬜ Key backup (encrypted, optional)
⬜ Sealed sender (hide sender metadata)
Phase 3:
⬜ Group Secret Chat (OMEMO groups)
⬜ Note to Self (encrypted personal notes)
15. NexGate Custom Namespaces
Complete Registry
All custom stanzas ALWAYS go inside <message>
Ejabberd routes without knowing what is inside
Frontend identifies by xmlns + type field
SOCIAL CARDS (VP Feed):
xmlns: urn:nexgate:feed:1
Types: POST_CARD, REEL_CARD, LIVE_CARD, PROFILE_CARD
COMMERCE CARDS (VP Shop):
xmlns: urn:nexgate:commerce:1
Types: PRODUCT_CARD, SHOP_CARD, FLASH_SALE_CARD
xmlns: urn:nexgate:offer:1
Types: CUSTOM_PRICE_OFFER, OFFER_RESPONSE, OFFER_EXPIRED
xmlns: urn:nexgate:groupbuy:1
Types: GROUP_BUY_CARD, GROUP_BUY_PROGRESS, GROUP_BUY_COMPLETED
xmlns: urn:nexgate:installment:1
Types: INSTALLMENT_PLAN_CARD
EVENT CARDS (VP Events):
xmlns: urn:nexgate:event:1
Types: EVENT_CARD, TICKET_CARD, EVENT_REMINDER, EVENT_GROUP_BUY
SYSTEM CARDS (Automated):
xmlns: urn:nexgate:system:1
Types: ORDER_CONFIRMATION, ORDER_STATUS_UPDATE,
PAYMENT_CONFIRMATION, REFUND_CARD, DISPUTE_CARD
GROUP + CALL CARDS (chat_system):
xmlns: urn:nexgate:group:1
Types: GROUP_INVITATION
xmlns: urn:nexgate:call:1
Types: CALL_INITIATED, CALL_ACCEPTED, CALL_DECLINED,
CALL_ENDED, CALL_MISSED, GROUP_CALL_JOIN_INFO,
PARTICIPANT_ADDED, HOST_MUTED_YOU, HOST_REMOVED_YOU
API TEMPLATE CARDS (Third-party Future):
xmlns: urn:nexgate:template:1
Types: TRANSACTIONAL, MARKETING, AUTHENTICATION
MEDIA:
xmlns: urn:nexgate:media:1
Types: IMAGE, VIDEO, VOICE_NOTE, FILE, STICKER, GIF
SECRET CHAT METADATA:
xmlns: urn:nexgate:secret:1
Types: SELF_DESTRUCT_TIMER, KEY_VERIFICATION
EPHEMERAL (always add no-store hint):
xmlns: urn:nexgate:states (recording state)
xmlns: urn:nexgate:forward (forward metadata)
Standard XEPs Used
XEP-0085 chatstates Typing indicators
XEP-0184 urn:xmpp:receipts Delivery ticks
XEP-0333 urn:xmpp:chat-markers:0 Read ticks
XEP-0308 message-correct:0 Edit message
XEP-0424 message-retract:0 Delete message
XEP-0444 urn:xmpp:reactions:0 Emoji reactions
XEP-0297 urn:xmpp:forward:0 Forward message
XEP-0461 urn:xmpp:reply:0 Reply/quote
XEP-0359 urn:xmpp:sid:0 Stable stanza IDs
XEP-0198 urn:xmpp:sm:3 Stream management
XEP-0199 urn:xmpp:ping Keepalive ping
XEP-0334 urn:xmpp:hints no-store hint
XEP-0384 axolotl (OMEMO) E2EE encryption
XEP-0166 urn:xmpp:jingle:1 Call signaling
XEP-0045 muc protocol Group chat
XEP-0280 urn:xmpp:carbons:2 Multi-device sync
XEP-0313 urn:xmpp:mam:2 Message archive
Mobile App Decision Tree
Message arrives:
Has urn:nexgate:feed:1 ?
POST_CARD → post preview + [View Post]
LIVE_CARD → live badge + [Watch Live]
Has urn:nexgate:commerce:1 ?
PRODUCT_CARD → product card + [View] [Chat]
Has urn:nexgate:offer:1 ?
PENDING → offer + timer + [Accept] [Decline]
ACCEPTED → "Offer Accepted"
EXPIRED → "Offer Expired"
Has urn:nexgate:groupbuy:1 ?
GROUP_BUY_CARD → progress bar + [Join]
Has urn:nexgate:event:1 ?
EVENT_CARD → event + [View] [Get Ticket]
TICKET_CARD → ticket QR code
Has urn:nexgate:system:1 ?
ORDER_CONFIRMATION → order + [Track]
ORDER_STATUS_UPDATE → status badge
PAYMENT_CONFIRMATION → payment details
Has urn:nexgate:call:1 ?
CALL_INITIATED → incoming call screen
GROUP_CALL_JOIN_INFO → [Join Call] [Decline]
CALL_MISSED → missed call indicator
HOST_MUTED_YOU → "You were muted by host"
HOST_REMOVED_YOU → "You were removed from call"
Has urn:nexgate:group:1 ?
GROUP_INVITATION → [Accept] [Decline]
Has urn:nexgate:media:1 ?
IMAGE → show image
VOICE_NOTE → waveform + play button
STICKER → full size, no bubble
GIF → auto-play loop
VIDEO → video player
FILE → download button
Has urn:nexgate:template:1 ?
TRANSACTIONAL → template card
MARKETING → card + unsubscribe
No xmlns match ?
Render as plain text (use body)
ALWAYS include body as fallback
Basic XMPP clients see fallback text
NexGate app renders rich card
16. RabbitMQ Event Pipeline
Exchange & Queue Setup
Exchange: nexgate.chat (topic exchange)
Queues:
nexgate.chat.messages ← all message events
nexgate.chat.presence ← online/offline events
nexgate.chat.calls ← call start/end events
nexgate.chat.rooms ← group create/destroy events
Routing keys:
chat.message.inbound → message arrived
chat.message.edited → message edited
chat.message.retracted → message deleted
chat.message.reaction → reaction added/removed
chat.presence.online → user came online
chat.presence.offline → user went offline
chat.call.initiated → call started
chat.call.ended → call ended
chat.room.created → group created
chat.room.destroyed → group deleted
Event Payload Structure
chat.message.inbound:
{
event_type: "message.inbound",
from_jid: "alice@nexgate.com/android",
to_jid: "bob@nexgate.com",
stanza_id: "1784107799294256",
origin_id: "msg-abc-123",
conversation_type: "DIRECT|GROUP",
room_jid: null | "conv-abc@conference.nexgate.com",
body: "Hello Bob!",
has_custom_namespace: true,
namespace: "urn:nexgate:commerce:1",
raw_stanza: "<message>...</message>",
timestamp: 1721210400000
}
chat.presence.offline:
{
event_type: "presence.offline",
jid: "alice@nexgate.com/android",
bare_jid: "alice@nexgate.com",
timestamp: 1721210400000
}
chat.call.ended:
{
event_type: "call.ended",
call_id: "call-xyz",
from_jid: "alice@nexgate.com",
to_jid: "bob@nexgate.com",
duration_seconds: 120,
call_type: "VOICE|VIDEO",
ended_reason: "normal|declined|missed|failed"
}
Spring Boot Consumer Responsibilities
@RabbitListener(queues = "nexgate.chat.messages")
fun onMessage(event: ChatMessageEvent) {
// 1. Parse event
// 2. Find or create conversation
// 3. Detect message type:
if (event.namespace == "urn:nexgate:commerce:1") {
// parse product card, link to product
} else if (event.namespace == "urn:nexgate:offer:1") {
// update offer session status
} else {
// plain message
}
// 4. Persist to chat.messages
// 5. Update conversation last_message
// 6. Check recipient online status
// If offline → send FCM via notification/
// 7. For commerce stanzas → trigger business flows
}
@RabbitListener(queues = "nexgate.chat.presence")
fun onPresence(event: PresenceEvent) {
// Update user last_seen in core.users
// Update conversation online indicators
}
17. Shop Inbox & Staff Access
Shop XMPP Identity
Every shop has its own JID:
$techstore → shop-456@nexgate.com
Staff members share this JID:
Staff A connects as: shop-456@nexgate.com/staff-1
Staff B connects as: shop-456@nexgate.com/staff-2
Customer always sees: TechStore (never staff name)
JID resource stripped before delivery ✅
Staff XMPP token:
Issued by Spring Boot when staff logs in
{ jid: "shop-456@nexgate.com", exp: ... }
Short-lived: 8 hours (staff shift)
Shop Inbox Routing
Customer messages shop:
to="shop-456@nexgate.com"
Ejabberd routes to available staff resource
(round-robin or first available)
Staff replies:
from="shop-456@nexgate.com/staff-1"
Spring Boot strips /staff-1 resource
Customer receives:
from="shop-456@nexgate.com" ✅
Customer never knows which staff replied ✅
Audit log:
Spring Boot records which staff_id sent each message
Internal only — never visible to customer
Legal requirement for disputes
Shop Chat Initiation Rules
ALWAYS ALLOWED (transactional — auto):
Order confirmed → system sends to buyer
Order shipped → system sends to buyer
Payment issue → system sends to buyer
ALLOWED (existing relationship):
Previous buyer → shop can message from Orders section
Active follower → shop can send from Customers section
Goes to existing thread OR message request
NEVER ALLOWED:
Cold message to random users ❌
System blocks at API level
"You can only message customers who
interacted with your shop"
18. Offline & Push Notification Flow
Message arrives for Bob (offline):
Ejabberd:
1. Detects Bob offline
2. Stores in Ejabberd offline queue
3. Fires RabbitMQ event: chat.message.inbound
+ is_recipient_offline: true
Spring Boot consumer:
1. Persists message to DB
2. Detects is_recipient_offline = true
3. Calls notification/ package:
- Has FCM token? → send FCM push ✅
- No FCM token? → try Textfy SMS ✅
- Priority: FCM first, SMS fallback
FCM payload:
{
to: "bob-fcm-token",
notification: {
title: "Alice",
body: "Hello Bob!"
},
data: {
conversation_id: "conv-abc",
message_id: "msg-001",
type: "chat"
}
}
Bob comes online:
Ejabberd delivers offline queue
Spring Boot receives delivery event
Updates message status → DELIVERED
Sends <received> receipt to Alice ✅
19. Multi-Device Support
The Problem
Kibuti has:
Phone (android) → alice@nexgate.com/android
Tablet (tablet) → alice@nexgate.com/tablet
Web (browser) → alice@nexgate.com/web
Both logged in simultaneously.
How do messages reach all devices?
How do sent messages sync across devices?
How does read status sync?
Solution — XEP-0280 Message Carbons
Message Carbons = automatic copy to all devices
Kibuti sends message from phone:
Phone → Ejabberd → Bob ✅
Ejabberd ALSO sends carbon copy to:
Kibuti's tablet ✅
Kibuti's web ✅
Result:
All Kibuti's devices see sent messages ✅
Conversation stays in sync ✅
No extra code needed in Spring Boot
Ejabberd handles it via mod_carbons
Enable in ejabberd.yml:
modules:
mod_carbons: {}
Resource Priority
Each device has a priority:
android: priority 10 (highest — phone)
tablet: priority 5
web: priority 1 (lowest)
When Juma sends to Kibuti:
Ejabberd delivers to HIGHEST priority device
= android (phone) gets it first
Carbon copies → all other devices
Priority set during session setup:
<presence>
<priority>10</priority>
</presence>
NexGate mobile sets:
Foreground app: priority 10
Background app: priority 0
Ejabberd routes to foreground device ✅
Read Status Sync Across Devices
Problem:
Kibuti reads message on phone
Tablet still shows unread badge
Solution — XEP-0333 Chat Markers sync via Carbons:
Phone sends <displayed> to Juma
Ejabberd carbons copy to tablet + web
Tablet receives: "Kibuti read this on phone"
Tablet clears unread badge ✅
This is automatic via mod_carbons ✅
XMPP JWT Per Device
Each device gets its OWN XMPP JWT:
Phone connects → POST /auth/login → xmppJwt-1
Tablet connects → POST /auth/refresh → xmppJwt-2
Web connects → POST /auth/login → xmppJwt-3
Each JWT has same JID but different resource:
{ jid: "usr-kibuti@nexgate.com", exp: ... }
Ejabberd assigns resource automatically:
usr-kibuti@nexgate.com/android (phone)
usr-kibuti@nexgate.com/tablet (tablet)
usr-kibuti@nexgate.com/web-abc (browser)
On logout (one device):
That device's JWT invalidated
Other devices unaffected ✅
Active Sessions Tracking
Spring Boot tracks active sessions in Redis:
Key: sessions:usr-kibuti
Value: SET {
"android:gajim.B3HKS5VE",
"tablet:dino.36aa17c4"
}
TTL: 24 hours (JWT expiry)
When checking if user is online:
Redis lookup → instant ✅
No DB query needed ✅
No Ejabberd REST call needed ✅
Updated by:
PresenceConsumer → user online → add to SET
PresenceConsumer → user offline → remove from SET
Multi-Device Message Archive (MAM)
XEP-0313 Message Archive Management:
All messages stored in Ejabberd archive (PostgreSQL)
New device login → fetches message history
"Give me messages since last week"
New device flow:
1. Kibuti logs in on new tablet
2. Tablet connects to Ejabberd
3. Tablet sends MAM query:
<iq type="set">
<query xmlns="urn:xmpp:mam:2">
<x><field var="start">
<value>2026-07-10T00:00:00Z</value>
</field></x>
</query>
</iq>
4. Ejabberd returns all messages since that date
5. Tablet renders full conversation history ✅
Spring Boot also has messages in chat.messages:
Mobile can fetch via REST: GET /chat/messages
Paginated, sorted, filtered
Two sources of truth:
Ejabberd MAM: raw XMPP archive
Spring Boot DB: structured business records
20. Redis — What to Cache & Why
The Golden Rule
Redis is for:
Data that changes frequently
Data read far more than written
Data where 1-2 second staleness is OK
Data that is expensive to compute
Redis is NOT for:
Primary source of truth
Data that must be 100% consistent
Large objects (images, files)
Data that needs complex queries
Cache 1 — Online Presence
Key: presence:usr-kibuti
Value: { status: "online", last_seen: timestamp, devices: [...] }
TTL: 5 minutes (refreshed by heartbeat)
Why Redis:
Checked on EVERY message send
"Is recipient online?" → Redis hit ✅
Without Redis: Ejabberd REST call per message ❌
At 7,000 msg/min: 7,000 Ejabberd REST calls/min ❌
With Redis: 7,000 Redis reads/min (microseconds) ✅
Updated by:
PresenceConsumer when user comes online
PresenceConsumer when user goes offline
Heartbeat pings every 60 seconds
Cache 2 — Active XMPP Sessions
Key: sessions:usr-kibuti
Value: SET of resource strings
TTL: 24 hours (JWT expiry)
Why Redis:
Know which devices are connected
Without Redis: Ejabberd REST call (user_resources)
With Redis: instant SET lookup ✅
Updated by:
PresenceConsumer on connect/disconnect
Cache 3 — Conversation Metadata
Key: conv:conv-abc123
Value: {
name: "Business Friends",
type: "GROUP",
member_count: 47,
last_message: "...",
last_updated: timestamp
}
TTL: 10 minutes
Why Redis:
Inbox list loads conversation metadata for EVERY conversation
User with 50 conversations → 50 DB reads ❌
With Redis: 50 cache hits ✅
Inbox load: <50ms vs 500ms ✅
Updated by:
MessageConsumer when new message arrives
GroupService when group changes
Cache 4 — Unread Count Per Conversation
Key: unread:usr-kibuti:conv-abc123
Value: integer (unread message count)
TTL: none (persist until read)
Why Redis:
Show unread badge on inbox
Without Redis: COUNT query per conversation ❌
With Redis: INCR / SET / GET (nanoseconds) ✅
Updated by:
MessageConsumer: INCR when message arrives for user
ReceiptConsumer: SET 0 when user sends <displayed>
Total unread across all conversations:
Key: unread:total:usr-kibuti
MessageConsumer: INCR
Reset: SET 0 when inbox opened
Cache 5 — Offer Session State
Key: offer:offer-abc123
Value: {
status: "PENDING",
expires_at: timestamp,
offer_price: 400000,
public_price: 450000
}
TTL: 30 minutes (offer validity)
Why Redis:
Offer expiry check on every [Accept] tap
Without Redis: DB read per check ❌
With Redis: instant lookup ✅
TTL = offer auto-expires at Redis level ✅
Updated by:
OfferLifecycleService on state change
Redis TTL handles expiry automatically
Cache 6 — Rate Limiting
Key: ratelimit:msg:usr-kibuti
Value: integer (message count in window)
TTL: 1 minute (sliding window)
Why Redis:
Prevent message spam
Without Redis: DB query per message ❌
With Redis: INCR + TTL = instant ✅
Rules:
Max 60 messages per minute per user
Max 10 group invitations per day
Max 5 concurrent call attempts
Implementation:
INCR ratelimit:msg:usr-kibuti
EXPIRE ratelimit:msg:usr-kibuti 60
If value > 60: reject with 429
Cache 7 — TURN Credentials
Key: turn:usr-kibuti
Value: { username, credential, ttl, urls }
TTL: 1 hour
Why Redis:
User may request TURN credentials multiple times
(call fails, retry, reconnect)
Without Redis: HMAC compute per request
With Redis: cache for 1 hour ✅
Same credentials valid for 1 hour anyway
Cache 8 — User Profile (For Chat Display)
Key: profile:usr-kibuti
Value: {
display_name: "Kibuti Mwangi",
avatar_url: "https://cdn.nexgate.com/...",
username: "kibuti",
is_verified: false
}
TTL: 30 minutes
Why Redis:
Chat inbox shows sender name + avatar
50 conversations × profile = 50 DB reads ❌
With Redis: 50 cache hits ✅
Updated by:
User updates profile → invalidate cache
Cache miss → read from core.users → cache
Cache 9 — Group Membership (Hot Groups)
Key: group:members:conv-abc123
Value: SET of user JIDs
TTL: 5 minutes
Why Redis:
Fan-out notification: "who is in this group?"
For push notifications when group member offline
Without Redis: DB query per group message ❌
With Redis: SMEMBERS in microseconds ✅
Only cache for ACTIVE groups (>10 msg/hour)
Inactive groups: read from DB directly
Redis Key Naming Convention
Pattern: {domain}:{entity}:{id}:{sub}
Examples:
presence:usr-kibuti
sessions:usr-kibuti
conv:conv-abc123
unread:usr-kibuti:conv-abc123
unread:total:usr-kibuti
offer:offer-abc123
ratelimit:msg:usr-kibuti
ratelimit:invite:usr-kibuti
turn:usr-kibuti
profile:usr-kibuti
group:members:conv-abc123
NEVER store:
Raw message content (use DB)
Files or media (use MinIO)
Financial data (use DB with audit)
Auth tokens as plain text (use Vault)
Cache Invalidation Strategy
Write-through (update cache + DB together):
Unread counts ← must be accurate
Offer status ← critical for commerce
Cache-aside (read DB on miss, then cache):
User profiles ← changes rarely
Conversation metadata ← OK if slightly stale
Group membership ← OK if 5 min stale
TTL-based eviction (let it expire):
Presence ← Ejabberd handles truth
TURN credentials ← expire with JWT
Rate limits ← sliding window
Event-based invalidation:
User updates profile → DEL profile:usr-{id}
Group settings change → DEL conv:{id}
Member joins group → SADD group:members:{id}
Member leaves group → SREM group:members:{id}
21. Best Practices & Anti-Patterns
✅ DO — Message Persistence
DO: Persist via RabbitMQ consumer (async)
Message arrives → Ejabberd delivers → fires event
Consumer persists to DB asynchronously
User experience not blocked by DB write ✅
DON'T: Persist via synchronous API call
Message arrives → Spring Boot REST call → DB
DB write blocks message delivery ❌
If DB slow → messages delayed ❌
If DB down → messages lost ❌
✅ DO — Online Status Checks
DO: Check Redis for online status
MessageConsumer:
val isOnline = redis.exists("presence:$userId")
if (!isOnline) sendFCM()
DON'T: Call Ejabberd REST for every message
val resources = ejabberd.getUserResources(userId)
if (resources.isEmpty()) sendFCM()
← This is an HTTP call per message ❌
← At scale: thousands of HTTP calls/min ❌
✅ DO — Stanza Building
DO: Build stanzas in dedicated StanzaBuilder class
val stanza = StanzaBuilder.productCard(product)
ejabberdClient.sendStanza(from, to, stanza)
DON'T: Build XML strings manually scattered in code
val xml = "<message><nexgate-commerce>..." ❌
← Error prone, hard to test, hard to maintain
✅ DO — Offer Expiry
DO: Use Redis TTL for expiry detection
redis.set("offer:$offerId", offer, ttl = 30.minutes)
// TTL fires → OfferExpiryListener → update DB
DON'T: Use scheduled DB queries
@Scheduled(fixedRate = 60000)
fun expireOffers() {
db.query("SELECT * FROM offers WHERE expires_at < NOW()")
}
← Hammers DB every minute ❌
← Redis TTL is instant and free ✅
✅ DO — Conversation Updates
DO: Update conversation in MessageConsumer
MessageConsumer:
1. Persist message
2. Update conversation.last_message (DB)
3. INCR unread count (Redis)
4. Update conv metadata cache (Redis)
DON'T: Fetch conversation on every message render
GET /chat/conversations → DB query every time ❌
Serve from Redis cache ✅
✅ DO — JID Handling
DO: Always strip resource for storage
Store: "alice@nexgate.com" (bare JID)
Never: "alice@nexgate.com/android" (full JID)
val bareJid = fullJid.substringBefore("/")
DON'T: Store full JID with resource
Resource changes between sessions ❌
Queries will break ❌
Exception: call routing (need specific device)
Full JID used only for Jingle call signaling
Not stored in DB
✅ DO — Commerce Stanzas
DO: Include <body> fallback in EVERY custom stanza
<message>
<body>Check out this product</body> ← fallback
<nexgate-commerce xmlns="urn:nexgate:commerce:1">
...
</nexgate-commerce>
</message>
DON'T: Send custom stanza without fallback
<message>
<nexgate-commerce xmlns="urn:nexgate:commerce:1">
...
</nexgate-commerce>
</message>
← Basic XMPP clients show nothing ❌
← Debugging nightmares ❌
✅ DO — Group Message Stanza ID
DO: Use stanza-id (room-assigned) for group references
// Reacting to a group message:
val stanzaId = message.getStanzaId() // by="room@..."
buildReaction(stanzaId, emoji)
DON'T: Use origin-id for group message references
val originId = message.getOriginId() // client-generated
buildReaction(originId, emoji) ← WRONG for groups ❌
Rule:
1:1 chat → reactions/edits reference origin-id
Group chat → reactions/edits reference stanza-id
✅ DO — Shop Staff Privacy
DO: Always strip staff resource from JID
Outbound to customer:
from = "shop-456@nexgate.com" ✅
(resource stripped by StanzaBuilder)
DON'T: Let staff JID leak to customer
from = "shop-456@nexgate.com/staff-john" ❌
Customer now knows staff name ❌
Privacy violation ❌
Implementation:
StanzaBuilder always strips resource
for any stanza from a shop account
✅ DO — Rate Limiting
DO: Rate limit at Redis level before processing
fun sendMessage(userId: String, ...) {
val key = "ratelimit:msg:$userId"
val count = redis.incr(key)
if (count == 1L) redis.expire(key, 60)
if (count > 60) throw RateLimitException()
// proceed with message
}
DON'T: Rate limit at DB level
val recentCount = db.query(
"SELECT COUNT(*) FROM messages
WHERE user_id = ? AND created_at > NOW() - INTERVAL '1 minute'"
)
← DB query per message ❌
← Slow and expensive ❌
✅ DO — Typing Indicators
DO: Add no-store hint to typing stanzas
<message type="chat">
<composing xmlns="http://jabber.org/protocol/chatstates"/>
<no-store xmlns="urn:xmpp:hints"/> ← ALWAYS include
</message>
DON'T: Let typing indicators get stored in MAM
Without no-store: Ejabberd archives the typing indicator
New device fetches history: sees "alice is typing..."
from 3 weeks ago ← makes no sense ❌
✅ DO — Offline Message Handling
DO: Let Ejabberd handle offline queuing + Redis for FCM decision
Ejabberd stores offline messages natively
Spring Boot checks Redis for online status
If offline → FCM via notification/
DON'T: Build your own offline queue in DB
val offlineMessages = db.save(message) ❌
Duplicates Ejabberd's built-in offline storage ❌
Two sources of truth ❌
✅ DO — Gzip Compression
DO: Enable Gzip on ALL REST responses
# application.properties
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml
Result:
Chat API responses: 25KB → 6KB ✅
Inbox load on 2G: 1.25s → 0.3s ✅
Zero code change required ✅
DON'T: Send uncompressed JSON to mobile on 2G
25KB per inbox load × 10 loads/day
= 250KB/day just for inbox
On Tanzania 2G data plans: expensive ❌
Anti-Pattern — DB Poll for Events
NEVER DO THIS:
@Scheduled(fixedRate = 1000)
fun checkForNewMessages() {
val messages = db.query(
"SELECT * FROM messages WHERE delivered_at IS NULL"
)
messages.forEach { deliver(it) }
}
← Polls DB every second ❌
← At scale: crushes DB ❌
← High latency (up to 1 second delay) ❌
DO THIS INSTEAD:
RabbitMQ consumer:
Ejabberd fires event → consumer reacts instantly ✅
Zero polling ✅
Sub-millisecond reaction time ✅
Anti-Pattern — N+1 Queries in Inbox
NEVER DO THIS:
val conversations = db.getConversations(userId)
conversations.forEach { conv ->
conv.lastMessage = db.getLastMessage(conv.id) // N queries ❌
conv.unreadCount = db.getUnreadCount(conv.id) // N queries ❌
conv.memberNames = db.getMemberNames(conv.id) // N queries ❌
}
50 conversations = 150 DB queries ❌
DO THIS INSTEAD:
// Redis for unread counts (O(1) per key)
val unreadCounts = redis.mget(convIds.map { "unread:$userId:$it" })
// Single JOIN query for last messages
val conversations = db.query("""
SELECT c.*, m.body as last_body, m.created_at as last_at
FROM chat.conversations c
LEFT JOIN chat.messages m ON m.id = c.last_message_id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
LIMIT 50
""")
// Profiles from Redis
val profiles = redis.mget(userIds.map { "profile:$it" })
Anti-Pattern — Blocking on Ejabberd REST
NEVER DO THIS (in request path):
fun sendMessage(req: MessageRequest): Response {
val result = ejabberdClient.sendMessage(...) // HTTP call
db.saveMessage(...)
return Response.ok()
}
← HTTP call in request path ❌
← If Ejabberd slow → user waits ❌
← If Ejabberd down → request fails ❌
DO THIS INSTEAD:
fun sendMessage(req: MessageRequest): Response {
// Validate + queue immediately
val messageId = db.saveOutgoing(req)
rabbitMQ.publish("chat.outbound", req)
return Response.ok(messageId) // instant ✅
}
// Async consumer sends to Ejabberd
@RabbitListener(queues = "chat.outbound")
fun sendToEjabberd(req: MessageRequest) {
ejabberdClient.sendMessage(...) // in background ✅
}
22. Package Structure
Full chat/ Package
chat/
├── config/
│ EjabberdConfig.java ← Ejabberd connection settings
│ RabbitMQChatConfig.java ← queues, exchanges, bindings
│ LiveKitConfig.java ← LiveKit server settings
│ CoturnConfig.java ← TURN server settings
│
├── controller/
│ ConversationController.java ← GET /chat/conversations
│ MessageController.java ← GET /chat/messages
│ GroupController.java ← POST /chat/groups/create
│ CallController.java ← GET /chat/calls/turn-credentials
│ ReceiptController.java ← POST /chat/messages/{id}/receipt
│
├── service/
│ ConversationService.java ← conversation lifecycle
│ MessageService.java ← persist, query messages
│ GroupService.java ← group management
│ OfferSessionService.java ← offer lifecycle
│ CallService.java ← call records, TURN creds
│ PresenceService.java ← online status tracking
│
├── consumer/
│ MessageConsumer.java ← handles chat.message.*
│ PresenceConsumer.java ← handles chat.presence.*
│ CallConsumer.java ← handles chat.call.*
│ RoomConsumer.java ← handles chat.room.*
│
├── ejabberd/
│ EjabberdRestClient.java ← HTTP client for Ejabberd API
│ EjabberdAuthController.java ← POST /internal/ejabberd/auth
│ JwksController.java ← GET /auth/.well-known/jwks.json
│ XmppTokenService.java ← generate/validate XMPP JWT
│
├── stanza/
│ StanzaBuilder.java ← builds XMPP stanzas
│ CommerceStanza.java ← product card, offer stanzas
│ GroupBuyStanza.java ← bei ya pamoja stanzas
│ SystemStanza.java ← order update stanzas
│ CallStanza.java ← group call join stanzas
│ MediaStanza.java ← file/image/voice stanzas
│
├── commerce/
│ CommerceDmService.java ← initiate commerce DM
│ OfferLifecycleService.java ← offer state machine
│ GroupBuyService.java ← bei ya pamoja flows
│ ShopInboxService.java ← shop routing, staff access
│
└── call/
TurnCredentialService.java ← HMAC TURN credentials
LiveKitService.java ← group call room management
CallRecordService.java ← persist call records
23. Infrastructure Stack
Docker Containers (Local Development)
Container Port(s) Purpose
──────────────────────────────────────────────────────────────
ejabberd 5222, 5280 XMPP server
coturn 3478 STUN/TURN relay
livekit 7880, 7881 Group calls + Audio Spaces
nexgate_postgres 5432 Main platform DB
ft_postgres 5433 File Thunder DB
ejabberd_postgres 5434 Ejabberd DB (production)
rabbitmq 5672, 15672 Message queue + management
redis 6379 Cache, sessions, rate limits
minio 9000, 9001 Object storage
ft_clamav 3310 Virus scanning
Local processes:
nexgate_backend 8080 Spring Boot (all-in-one)
file_thunder 8084 Media engine
Ejabberd Configuration Summary
ejabberd.yml key settings:
hosts: ["nexgate.com"]
auth_method: jwt
jwt_key: https://api.nexgate.com/auth/.well-known/jwks.json
listen:
port 5222: ejabberd_c2s (XMPP + TLS)
port 5280: ejabberd_http (/api, /admin, /oauth, /ws)
modules:
mod_muc: group chats
mod_mam: message archive
mod_offline: offline message storage
mod_ping: keepalive
mod_stream_mgmt: XEP-0198 reliability
mod_rabbitmq: event publishing
default_db: sql (production)
sql_type: pgsql
sql_server: "ejabberd_postgres"
sql_database: "ejabberd"
Ejabberd ↔ Spring Boot Security
Channel 1 (Spring Boot → Ejabberd REST):
Admin credentials in HashiCorp Vault
Internal Docker network only
Traefik blocks external access to 5280
Channel 2 (Ejabberd → Spring Boot via RabbitMQ):
RabbitMQ credentials in Vault
Internal Docker network only
AMQP not exposed externally
Channel 3 (Mobile → Ejabberd JWT):
JWT signed RS256 (private key in Vault)
Public key via JWKS endpoint (HTTPS only)
XMPP port 5222 with TLS
JWT expires 24 hours
24. Build Order
Week 1 — Foundation
Day 1-2: JWT Auth Infrastructure
EjabberdAuthController.java
POST /internal/ejabberd/auth
Validate XMPP JWT, return 200/401
JwksController.java
GET /auth/.well-known/jwks.json
Return RSA public key
XmppTokenService.java
Generate XMPP JWT on login
Add to auth/LoginResponse
Configure Ejabberd:
auth_method: jwt
jwt_key: JWKS URL
Test: Dino/Gajim connects via JWT ✅
Day 3-4: RabbitMQ Event Pipeline
RabbitMQChatConfig.java
Declare exchange, queues, bindings
MessageConsumer.java
Listen to chat.message.inbound
Persist to chat.messages
PresenceConsumer.java
Listen to chat.presence.*
Update last_seen
Test: Send message in Gajim → appears in DB ✅
Day 5: Conversation Management
ConversationService.java
Find or create conversation
Update last_message
ConversationController.java
GET /chat/conversations (inbox)
GET /chat/conversations/{id}/messages
Week 2 — Core Messaging
Day 1-2: Message Interactions
Handle edit stanzas (XEP-0308)
Handle retract stanzas (XEP-0424)
Handle reaction stanzas (XEP-0444)
Handle reply stanzas (XEP-0461)
MessageService.java for each
Day 3: Group Chat
GroupController.java
POST /chat/groups/create
POST /chat/groups/{id}/join
POST /chat/groups/{id}/decline
DELETE /chat/groups/{id}/leave
GroupService.java
Ejabberd REST: create_room, add_member
Invite link management
Day 4-5: Receipts & Notifications
ReceiptController.java
POST /chat/messages/{id}/receipt
{ status: DELIVERED|READ }
Offline push:
MessageConsumer → detect offline
Call notification/ package
FCM push ✅
Week 3 — Commerce DMs
Day 1-2: Commerce Stanza Sending
EjabberdRestClient.java
sendStanza(from, to, body, customElement)
StanzaBuilder.java
buildProductCard(product)
buildOfferCard(offer)
buildSystemMessage(order)
CommerceDmService.java
initiateCommerceDm(productId, shopId, buyerId)
Day 3-4: Offer Session Lifecycle
OfferLifecycleService.java
createOffer(...)
acceptOffer(offerId)
declineOffer(offerId)
expireOffers() ← scheduled job
Day 5: Shop Inbox
ShopInboxService.java
Route to available staff
Strip staff resource from JID
Audit log staff actions
Week 4 — Calls
Day 1-2: TURN Credentials
TurnCredentialService.java
Generate HMAC-SHA256 credentials
Time-limited (1 hour)
CallController.java
GET /chat/calls/turn-credentials
Day 3-4: Group Calls (LiveKit)
LiveKitService.java
createRoom(callId)
generateToken(userId, roomId)
CallStanza.java
buildGroupCallJoinInfo(callId, token, url)
Spring Boot sends join info stanza
Members receive → join LiveKit room
Day 5: Call Records
CallRecordService.java
onCallInitiated(event)
onCallEnded(event)
getCallHistory(userId)
Week 5 — Polish & Testing
Message search (MAM queries to Ejabberd)
Read count for group messages
Block/unblock users
Message request system
Integration testing with Gajim/Dino
Load testing with sendxmpp
Documentation for mobile dev team
NexGate Chat System — Development Architecture Guide v4.0 QBIT SPARK | chat_system · Spring Boot · Ejabberd · RabbitMQ · Redis · WebRTC · Coturn · LiveKit · OMEMO
No comments to display
No comments to display