26 KiB
ajet-chat — Team Messaging Platform
Overview
Clojure monorepo for a team messaging app with multiple clients. PostgreSQL everywhere (dev + prod) via next.jdbc + HoneySQL.
Architecture
ajet-chat/
├── shared/ — Common DB layer, schemas, protocols, EventBus (NATS), API client SDK
├── auth-gw/ — Auth gateway: Clojure edge proxy, authn/authz, custom DB reads/writes
├── api/ — Stateless REST API: all reads & writes, publishes to event bus
├── web-sm/ — Web session manager: Hiccup + Datastar SSE for browsers
├── tui-sm/ — TUI session manager: SSE for terminal clients
├── cli/ — Terminal client: CLI args (one-off) + TUI (interactive), babashka/bbin
└── mobile/ — Mobile client + session manager (deferred)
Modules reference each other via :local/root in deps.edn.
Unified Deps
All server modules (auth-gw, api, web-sm, tui-sm):
- org.clojure/clojure 1.12.0
- http-kit/http-kit 2.8.0
- metosin/reitit 0.7.2
- ring/ring-core 1.13.0
- ajet/chat-shared (local) — EventBus (NATS via io.nats/jnats), schemas, protocols
PG access (api + auth-gw only):
- next.jdbc + HoneySQL + PostgreSQL driver (via shared/)
- migratus (for DB migrations)
Additional per-module:
- web-sm: hiccup 2.0.0-RC4, dev.data-star.clojure/sdk 1.0.0-RC5
- cli: babashka, clojure-tui (local)
Dependency matrix:
PG NATS API(HTTP)
API ✓ pub —
Auth GW ✓ — —
Web SM — sub ✓ (internal)
TUI SM — sub ✓ (internal)
CLI — — ✓ (external, via Auth GW)
System Architecture
Internet
│
▼
┌──────────────────────────────────────────────────────┐
│ nginx (TLS termination, prod only) │
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ Auth Gateway (Clojure, http-kit) │
│ • Session token + API token validation (DB reads) │
│ • Session creation on login (DB writes) │
│ • Rate limiting, CORS, audit logging │
│ • Routes authed requests to internal services │
│ • SSE connection auth │
└──┬──────────────┬───────────────┬──────────┬─────────┘
│ │ │ │
│ /api/* │ / (web) │ /ws/tui │ /ws/mobile
▼ ▼ ▼ ▼
┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ API │ │ Web SM │ │ TUI SM │ │Mobile SM │
│(REST) │ │(Datastar │ │(SSE) │ │(future) │
│ │ │ SSE) │ │ │ │ │
└──┬────┘ └──┬──┬────┘ └──┬──┬────┘ └──┬──┬────┘
│ │ │ │ │ │ │ │
│ │ │ browsers │ terminals │ phones
│ │ │ │ │
│ │ pub │ sub │ sub │ sub
│ ▼ ▼ ▼ ▼
│ ┌────────────────────────────────────────────┐
│ │ NATS │
│ │ pub/sub event bus · community-scoped │
│ │ subjects · JetStream for replay │
│ └────────────────────────────────────────────┘
│ r/w
▼
┌──────────────────────────────────────────────────────┐
│ PostgreSQL │
│ data store · full-text search │
│ ◄── Auth GW: session reads/writes │
└──────────────────────────────────────────────────────┘
Auth Gateway
Clojure-based edge proxy (http-kit). All external traffic enters here.
- Custom DB reads: session token + API token validation
- Custom DB writes: session creation, rate limit counters, audit log
- Authenticates HTTPS and SSE connections
- Proxies to internal services after authentication
- In prod: sits behind nginx for TLS termination
Auth Strategy
- OAuth for v1 — all user login via OAuth providers (GitHub, Gitea)
- Auth GW handles OAuth callback, creates/links user, creates session
- No local passwords in v1
- Email-based session auth for local-only accounts planned for later (requires email infrastructure)
- Session tokens for interactive users (web, TUI, mobile)
- 32 random bytes (256 bits), encoded as base64url, stored as bcrypt hash in DB
- Rolling expiry: TTL extends on each authenticated request
- Cookie for web (
HttpOnly,Secure,SameSite=Strict),Authorization: Bearerheader for CLI/mobile
- API tokens for programmatic access
- Long-lived, scoped tokens for bots, integrations, scripts
- Created and managed via admin UI in each client
- Incoming webhooks for external service integration
- Each webhook is bound to a specific channel + has a unique URL + secret token
- External services POST JSON to
/hook/<webhook-id>/<token> - Auth GW validates the webhook token (no session needed)
- API creates the message in the bound channel, broadcasts via EventBus
- Webhook messages display with a bot name/avatar (configurable per webhook)
- Use cases: git push notifications, CI/CD status, server alerts, monitoring
- Managed via slash commands:
/webhook create #channel <name>,/webhook list,/webhook revoke <id>
- Slash commands — universal interaction model across all clients
- Typed in the message input (web, TUI) or as CLI args
- API parses and executes, returns result or side-effect
- Regular user commands:
/join #channel,/leave,/topic new topic,/msg @user, etc. - Admin commands (role-gated: owner/admin only):
/ban @user [reason],/kick @user,/mute @user [duration],/token create <name> [scopes],/token revoke <id>,/role @user admin|member,/purge #channel [count] /helplists available commands (filtered by user's role)/help <command>shows detailed usage for a specific command- All backed by the same API routes — clients just send the command string, API handles parsing, permission checks, and execution
First-User Bootstrap
On first server setup (no communities exist in DB):
- Auth GW login page shows a community creation form alongside OAuth buttons
- First user authenticates via OAuth, creates the initial community, becomes owner
- After initial community exists, new users must be invited (invite-only)
Service Ports (Dev)
Hardcoded per service for now. Config files / env vars can be layered later.
- Auth GW generates a unique
X-Trace-Idon each inbound request; all downstream services propagate it
API
Pure stateless REST (http-kit + reitit). Only service (besides Auth GW) with a PG connection. All data reads and writes. Publishes events to NATS after writes. No live connections. Scales trivially.
Session Managers
Each manages live connections for its client type (http-kit + reitit):
- Web SM: serves Hiccup pages, holds Datastar SSE connections, pushes HTML fragments
- TUI SM: holds SSE connections for terminal clients (HTTP POSTs for client→server signals)
- Mobile SM: same pattern for mobile (future)
Data flow for session managers:
- All data reads (channel list, message history, user info, full message after notification): via API
- Live events (new messages, typing, presence): via NATS subscription
- Ephemeral writes (typing indicators): SM publishes directly to NATS — no API round-trip
SMs call the API directly (bypass Auth GW) and propagate the user's token from the original request. The API validates the token the same way regardless of caller — no separate service tokens needed. All data reads go through the API — keeps auth and permission checks centralized. Independently scalable.
SM subscription model: When a user connects, their SM subscribes to:
- Community event subject for their active community (
chat.events.{community-id}) - All their DM channel IDs for DM events (
chat.dm.{channel-id}) - Per-user notification subject (
chat.notifications.{user-id})
EventBus — NATS
Dedicated pub/sub via NATS. Lightweight (~20MB), single binary, starts in milliseconds. Runs as a Docker container alongside Postgres and MinIO.
(defprotocol EventBus
(publish! [this topic event])
(subscribe! [this topic handler])
(unsubscribe! [this topic handler]))
- API publishes to NATS after DB writes (e.g.,
chat.events.{community-id}) - Session managers subscribe to NATS for real-time events — no PG connection needed
- SMs publish ephemeral events (typing) directly to NATS — no API round-trip
- SMs fetch full data from the API when needed (e.g., full message after receiving a notification ID)
- NATS subjects are community-scoped — natural sharding boundary
- JetStream (NATS persistence layer) enables replay for SSE reconnection (
Last-Event-ID) - Clojure client:
io.nats/jnats(official Java client)
NATS subject hierarchy:
chat.events.{community-id} — channel message CRUD, channel events, reactions
chat.dm.{channel-id} — DM message events (global, no community)
chat.typing.{community-id}.{channel-id} — channel typing indicators
chat.typing.dm.{channel-id} — DM typing indicators
chat.presence.{community-id} — heartbeat/presence events
chat.notifications.{user-id} — per-user notification delivery
Event envelope (hybrid fat events):
{
"type": "message.created",
"community_id": "uuid or null",
"channel_id": "uuid",
"actor_id": "uuid",
"timestamp": "ISO-8601",
"data": { /* enough to render the common case */ }
}
dataincludes body, author ID, timestamp — enough to render without a follow-up API call- Does NOT include deeply nested or volatile data (e.g. full author profile — SMs cache separately)
- Schema evolves additively only: add fields, never remove. Consumers ignore unknown fields.
Data Model
Slack-like with planned voice channel support.
Core Entities
Community
└── Channel (text / voice*, public / private)
└── Message
├── Thread reply (1 level deep)
└── Reaction (emoji per user)
Conversation (global, not community-scoped)
└── Message (same table, via shared container)
User
└── CommunityMembership (user <> community, role)
└── ChannelMembership (user <> channel)
└── ChannelMembership (user <> channel, includes DMs)
APIUser (bot/integration accounts, managed via admin UI in each client)
*voice channels: planned, not in v1
DMs — Global, Not Community-Scoped
DMs are just channels with community_id = NULL and type dm or group_dm.
No extra tables needed — channels, channel_members, and messages all work as-is.
- DM: 1:1 channel between two users (
type = 'dm',community_id = NULL) - Group DM: channel with 2+ users (
type = 'group_dm',community_id = NULL) - Starting a DM auto-creates a channel if one doesn't exist
- DM history persists across communities — same channel everywhere
- UI shows one community at a time; DM sidebar is always visible (global)
URL Routing
- Community-scoped routes:
/c/<slug>/channels/...— channels, messages, members - Global DM routes:
/dm/...— DM channels, DM messages - Other global routes:
/api/users/...,/api/notifications/... - UI shows one community at a time; DM sidebar is always visible (global)
- Users can be members of multiple communities
API Pagination
Cursor-based pagination using entity UUIDs as cursors:
- Forward:
?after=<uuid>&limit=N - Backward:
?before=<uuid>&limit=N - Applies to messages, channels, notifications, and any other list endpoints
Messages
- Body: Markdown (rendered to HTML in web, terminal escape codes in CLI)
- Attachments: file/image uploads stored in MinIO (S3-compatible)
- Pings/mentions:
@user,@channel,@here— parsed from markdown, stored as structured refs - Threads: replies link to a parent message. 1 level deep (no nested threads)
- Reactions: emoji reactions per message. Any unicode emoji, one per user per emoji.
PUT /api/messages/:id/reactions/:emoji— toggle on (idempotent)DELETE /api/messages/:id/reactions/:emoji— toggle off- API publishes reaction events to EventBus; SMs push live updates to clients
- When fetching messages, reactions are returned aggregated:
[{emoji, count, reacted_by_me}]
Key Tables
All id columns are UUIDs (generated server-side, java.util.UUID/randomUUID).
communities (id:uuid, name, slug, created_at)
users (id:uuid, username, display_name, email, avatar_url, created_at)
oauth_accounts (id:uuid, user_id:uuid, provider [github/gitea/...], provider_user_id, created_at)
api_users (id:uuid, name, community_id:uuid, scopes, created_at)
sessions (id:uuid, user_id:uuid, token_hash, expires_at, created_at)
api_tokens (id:uuid, api_user_id:uuid, token_hash, scopes, expires_at, created_at)
channels (id:uuid, community_id:uuid?, name, type [text/voice/dm/group_dm], visibility [public/private], topic, created_at)
community_members (community_id:uuid, user_id:uuid, role [owner/admin/member], nickname, avatar_url)
channel_members (channel_id:uuid, user_id:uuid, joined_at)
messages (id:uuid, channel_id:uuid, user_id:uuid, parent_id:uuid [null=top-level], body_md, created_at, edited_at)
attachments (id:uuid, message_id:uuid, filename, content_type, size_bytes, storage_key)
webhooks (id:uuid, community_id:uuid, channel_id:uuid, name, avatar_url, token_hash, created_by:uuid, created_at)
reactions (message_id:uuid, user_id:uuid, emoji:text, created_at) PK: (message_id, user_id, emoji)
mentions (id:uuid, message_id:uuid, target_type [user/channel/here], target_id:uuid)
notifications (id:uuid, user_id:uuid, type [mention/dm/thread_reply/invite/system], source_id:uuid, read:bool, created_at)
Note: DMs are channels with community_id = NULL. Community channels always have
community_id set. Queries for community channels filter by community_id;
DM queries filter by type IN ('dm', 'group_dm') AND community_id IS NULL.
Presence (Heartbeats)
- Clients send
POST /api/heartbeatevery 60 seconds - API returns 200 immediately, writes async (fire-and-forget)
- Updates
last_seen_aton user record (or dedicated presence table) - User is "online" if last heartbeat < 2 min ago, "offline" otherwise
- Typing indicators: live via NATS, not batched, not persisted
- Client sends typing event to their session manager (via HTTP POST)
- SM publishes to NATS (
chat.typing.{community}.{channel}) — does not go through API typing:start— sent on keypress, delivered immediately to relevant clients (same channel/DM)typing:stop— sent explicitly when user clears their input box or sends a message- Auto-expire: if no new keypress for 15 seconds, client stops sending
typing:start; receiving clients timeout and hide the indicator after 15s of silence
- Session manager presence delivery:
- SMs collect heartbeat events from NATS into a buffer
- Once per minute, flush a batched presence update to each connected client
- Filter per client: only include users relevant to that client (same channels, active DMs — not the entire community)
- Presence update is a diff: only users whose status changed (online→offline or vice versa)
- Clients never receive raw heartbeat events — only batched, filtered presence snapshots
Notifications
Persisted per-user. Syncs across all clients — unread on web shows unread on CLI and TUI.
- Triggers (API creates notification rows on):
- @mention (user, channel, here)
- DM received
- Thread reply to a message you authored or participated in
- Channel invite
- System events (role change, ban, etc.)
notificationstable stores one row per user per event, withread:boolsource_idpoints to the originating entity (message, channel, etc.) based ontype- Read/unread state: clients mark notifications read via API (
POST /api/notifications/read) - Real-time: EventBus publishes notification events; session managers deliver immediately
- Query:
GET /api/notifications?unread=true— any client can fetch current unread count/list mentionstable remains for structured @mention data;notificationsis the user-facing delivery layer
Database Strategy
- PostgreSQL everywhere (dev + prod). Dev Postgres runs in Docker.
- Shared DB layer in
shared/uses next.jdbc + HoneySQL - Avoid raw SQL strings; use HoneySQL maps everywhere
- Schema migrations: migratus (SQL migration files, works with next.jdbc)
- PostgreSQL is a pure data store — no pub/sub responsibility
File Storage
S3-compatible object storage via MinIO. Same S3 API everywhere — portable to AWS later.
- Dev: MinIO container (alongside Postgres in docker-compose.dev.yml)
- Prod self-hosted: MinIO container in Docker Compose alongside other services
- Prod AWS (future): swap endpoint URL to real S3, zero code changes
- API handles uploads: validates, stores in MinIO, writes
attachmentsrow withstorage_key storage_keyformat:<community-id>/<channel-id>/<message-id>/<filename>- Clojure S3 client: amazonica or cognitect aws-api (both work with MinIO via endpoint override)
- Avatars, webhook bot icons also stored in MinIO
Development Workflow
REPL-driven development. Infrastructure (Postgres, MinIO) runs in Docker. Clojure services run locally via nREPL — not in containers.
- REPL-first: start services from the REPL, hot-reload code, eval as you go
- CIDER middleware: nrepl, cider-nrepl, refactor-nrepl in
:devalias - Docker for infra only:
docker-compose.dev.ymlruns Postgres + MinIO + NATS - EventBus: NATS — same as prod, no mock/shim
- Dev without auth gateway: services run directly, auth bypassed or mocked
REPL Options
- Single REPL — start one JVM with all modules loaded. Good for early dev.
clj -A:dev:api:web-sm:tui-sm:auth-gw— all services in one process, shared Postgres + NATS. - Multiple REPLs — one per service. Better isolation, closer to prod topology. Each connects its own nREPL on a different port.
Dev Aliases
clj -M:dev:api # API service + CIDER nREPL
clj -M:dev:web-sm # Web SM + CIDER nREPL
clj -M:dev:tui-sm # TUI SM + CIDER nREPL
clj -M:dev:auth-gw # Auth gateway + CIDER nREPL
Each :dev alias includes nrepl + cider-nrepl + refactor-nrepl.
Services expose (start!) / (stop!) / (reset!) functions for REPL control.
Testing
Three tiers, escalating in scope and infrastructure.
Unit Tests — no external deps
Fast, pure-function tests. No Docker, no DB, no HTTP.
- Test runner: Kaocha (
:test/unitalias) - What's tested: validation, parsing, formatting, HoneySQL query builders, markdown processing, permission logic, slash command parsing, data transforms
- DB layer tested via protocol mocks/stubs — verify queries are built correctly without executing them
- Run:
clj -M:test/unit— takes seconds, runs in CI on every push
Integration Tests — dockerized infra
Test service internals against real Postgres + MinIO + NATS.
docker-compose.test.ymlspins up Postgres + MinIO + NATS with test-specific ports and a fresh DB (no data carried between runs)- Tests run on the host JVM, connecting to Docker infra
- What's tested: DB migrations, repository functions (actual SQL round-trips), EventBus (real NATS pub/sub), file upload/download, API route handlers with a real DB behind them
- Each test namespace gets a transaction that rolls back — tests don't leak state
- Run:
docker compose -f docker-compose.test.yml up -d && clj -M:test/integration - CI: Docker services started as job services, tests run in the pipeline
E2E Tests — full stack in Docker
Production-like topology. Everything containerized.
docker-compose.test.ymlwith additional service containers: nginx + auth-gw + api + web-sm + tui-sm (built from uberjars)- Tests run from outside the stack as a client would
- What's tested: full request lifecycle (auth → API → DB → event → SM → client), SSE connections, webhook delivery, multi-user scenarios, cross-service event propagation
- HTTP client for API + SM tests (SSE consumer for streaming)
- Playwright for web UI tests (page loads, live updates, reactions, typing indicators)
- Run:
docker compose -f docker-compose.test.yml --profile e2e up -d && clj -M:test/e2e
Test Aliases
clj -M:test/unit # unit tests — no Docker needed
clj -M:test/integration # integration — requires docker-compose.test.yml
clj -M:test/e2e # end-to-end — requires full stack in Docker
clj -M:test/all # all tiers sequentially
Docker Compose Files
docker-compose.dev.yml # dev: Postgres + MinIO + NATS
docker-compose.test.yml # test: Postgres + MinIO + NATS (fresh DB per run)
# --profile e2e adds all service containers
docker-compose.yml # prod: full stack
Deployment (Prod)
- Docker Compose for production
- Each service as a container: auth-gw, api, web-sm, tui-sm
- PostgreSQL + MinIO + NATS + nginx as infrastructure containers
- docker-compose.yml defines the full topology
- Uberjars built per service
Key Decisions Made
- Monorepo with :local/root deps
- http-kit + reitit + Ring for all server modules (no Pedestal)
- Hiccup + Datastar for web frontend (no ClojureScript)
- Datastar Clojure SDK (dev.data-star.clojure/sdk) with http-kit adapter
- Single
ajet-chatexecutable: CLI mode (one-off) + TUI mode (interactive) - clojure-tui for TUI rendering, babashka/bbin for distribution
- PostgreSQL everywhere (dev + prod), no SQLite
- Persistent writes through API, event bus for real-time fan-out
- EventBus: NATS — dedicated pub/sub, community-scoped subjects, JetStream for replay
- API is stateless REST — no live connections
- Session managers per client type — each independently scalable
- Session managers: NATS for events, API for all data reads — no direct PG connection
- SMs publish ephemeral events (typing) directly to NATS — bypasses API for latency
- Auth gateway in Clojure (http-kit) — custom DB reads/writes for auth
- Auth: OAuth-only for v1 (GitHub, Gitea), email-based local auth later
- Auth: session tokens + API tokens for programmatic access
- Slash commands as universal interaction model (regular + admin, role-gated)
- No separate admin module — admin is just elevated slash commands
- Incoming webhooks for external integrations (git, CI, monitoring)
- MinIO for file/attachment storage (S3-compatible, portable to AWS)
- Presence via heartbeats: 1 min intervals, async DB write, instant 200 response
- Slack-like data model: communities > channels > messages > threads (1 deep)
- Global DMs: channels with
community_id = NULL, typedm/group_dm— no extra tables - Multi-community: users can join multiple communities, UI shows one at a time
- Path-based community routing:
/c/<slug>/...for community,/dm/...for global DMs - Voice channels: LiveKit SFU, TUI opens browser for audio (not v1)
- Search: PG tsvector + GIN index, Meilisearch upgrade path
- Messages: markdown + file uploads + @mentions/pings
- Mobile: deferred, focus on web + CLI first
- nginx for TLS termination in prod
- Migratus for DB migrations
- Docker Compose for prod; docker-compose.dev.yml for dev infra (Postgres + MinIO + NATS)
- REPL-driven dev: nrepl + cider-nrepl + refactor-nrepl
- Clojure services run locally via REPL, not in containers
- Three-tier testing: unit (no deps) → integration (Docker infra) → E2E (full stack)
- Kaocha test runner, separate aliases per tier
- E2E: Playwright for web, HTTP/SSE client for API/SM
Planned (not v1)
Voice Channels — LiveKit SFU
- Self-hosted LiveKit (Go binary, Docker container) as the SFU
- Web/mobile: full WebRTC audio via LiveKit JS/native SDKs
- TUI: voice channel state display (who's in, mute status) + opens browser link for audio
- LiveKit Java SDK for Clojure interop (room management, token generation)
Search — PostgreSQL Full-Text Search
- PostgreSQL tsvector + GIN index (dev and prod)
- Zero extra infrastructure
- Supports stemming, ranking, phrase search
- Upgrade path: swap in Meilisearch behind the same API endpoint if needed later