Files
ajet-chat/plan.md
2026-02-17 00:23:25 -05:00

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: 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/<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]
    • /help lists 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):

  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. 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 */ }
}
  • 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/<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/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: <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 :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

  • 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-chat executable: 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, type dm/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)
  • 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