# 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: Bearer` header 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//` - 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 `, `/webhook list`, `/webhook revoke ` - **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 [scopes]`, `/token revoke `, `/role @user admin|member`, `/purge #channel [count]` - `/help` lists available commands (filtered by user's role) - `/help ` 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): 1. Auth GW login page shows a community creation form alongside OAuth buttons 2. First user authenticates via OAuth, creates the initial community, becomes owner 3. 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-Id` on 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: 1. Community event subject for their active community (`chat.events.{community-id}`) 2. All their DM channel IDs for DM events (`chat.dm.{channel-id}`) 3. Per-user notification subject (`chat.notifications.{user-id}`) ### EventBus — NATS Dedicated pub/sub via [NATS](https://nats.io). Lightweight (~20MB), single binary, starts in milliseconds. Runs as a Docker container alongside Postgres and MinIO. ```clojure (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):** ```json { "type": "message.created", "community_id": "uuid or null", "channel_id": "uuid", "actor_id": "uuid", "timestamp": "ISO-8601", "data": { /* enough to render the common case */ } } ``` - `data` includes 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//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=&limit=N` - Backward: `?before=&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/heartbeat` every 60 seconds - API returns 200 immediately, writes async (fire-and-forget) - Updates `last_seen_at` on 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.) - `notifications` table stores one row per user per event, with `read:bool` - `source_id` points to the originating entity (message, channel, etc.) based on `type` - **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 - `mentions` table remains for structured @mention data; `notifications` is 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 `attachments` row with `storage_key` - `storage_key` format: `///` - 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 `:dev` alias - **Docker for infra only**: `docker-compose.dev.yml` runs Postgres + MinIO + NATS - **EventBus**: NATS — same as prod, no mock/shim - **Dev without auth gateway**: services run directly, auth bypassed or mocked ### REPL Options 1. **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. 2. **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/unit` alias) - 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.yml` spins 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.yml` with 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 - [x] Monorepo with :local/root deps - [x] http-kit + reitit + Ring for all server modules (no Pedestal) - [x] Hiccup + Datastar for web frontend (no ClojureScript) - [x] Datastar Clojure SDK (dev.data-star.clojure/sdk) with http-kit adapter - [x] Single `ajet-chat` executable: CLI mode (one-off) + TUI mode (interactive) - [x] clojure-tui for TUI rendering, babashka/bbin for distribution - [x] PostgreSQL everywhere (dev + prod), no SQLite - [x] Persistent writes through API, event bus for real-time fan-out - [x] EventBus: NATS — dedicated pub/sub, community-scoped subjects, JetStream for replay - [x] API is stateless REST — no live connections - [x] Session managers per client type — each independently scalable - [x] Session managers: NATS for events, API for all data reads — no direct PG connection - [x] SMs publish ephemeral events (typing) directly to NATS — bypasses API for latency - [x] Auth gateway in Clojure (http-kit) — custom DB reads/writes for auth - [x] Auth: OAuth-only for v1 (GitHub, Gitea), email-based local auth later - [x] Auth: session tokens + API tokens for programmatic access - [x] Slash commands as universal interaction model (regular + admin, role-gated) - [x] No separate admin module — admin is just elevated slash commands - [x] Incoming webhooks for external integrations (git, CI, monitoring) - [x] MinIO for file/attachment storage (S3-compatible, portable to AWS) - [x] Presence via heartbeats: 1 min intervals, async DB write, instant 200 response - [x] Slack-like data model: communities > channels > messages > threads (1 deep) - [x] Global DMs: channels with `community_id = NULL`, type `dm`/`group_dm` — no extra tables - [x] Multi-community: users can join multiple communities, UI shows one at a time - [x] Path-based community routing: `/c//...` for community, `/dm/...` for global DMs - [x] Voice channels: LiveKit SFU, TUI opens browser for audio (not v1) - [x] Search: PG tsvector + GIN index, Meilisearch upgrade path - [x] Messages: markdown + file uploads + @mentions/pings - [x] Mobile: deferred, focus on web + CLI first - [x] nginx for TLS termination in prod - [x] Migratus for DB migrations - [x] Docker Compose for prod; docker-compose.dev.yml for dev infra (Postgres + MinIO + NATS) - [x] REPL-driven dev: nrepl + cider-nrepl + refactor-nrepl - [x] Clojure services run locally via REPL, not in containers - [x] Three-tier testing: unit (no deps) → integration (Docker infra) → E2E (full stack) - [x] Kaocha test runner, separate aliases per tier - [x] 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