init commit

This commit is contained in:
2026-02-17 00:23:25 -05:00
commit 79b6a5e225
25 changed files with 1648 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.cpcache/
.nrepl-port
target/
*.db
.clj-kondo/.cache/
.lsp/
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ajet-chat is a Clojure monorepo for a team messaging platform (Slack-like). Early stage — module skeletons exist but most code is stubbed out.
## Repository Structure
Monorepo with `:local/root` deps linking modules:
- **shared/** — Common DB layer (next.jdbc + HoneySQL), EventBus (NATS via jnats), API client SDK, schemas, protocols
- **auth-gw/** — Auth gateway: http-kit edge proxy, session/token validation, routing (has PG access)
- **api/** — Stateless REST API: all data reads/writes to PG, publishes events to NATS
- **web-sm/** — Web session manager: Hiccup + Datastar SSE for browsers (NATS + API, no PG)
- **tui-sm/** — TUI session manager: SSE for terminal clients (NATS + API, no PG)
- **cli/** — Terminal client: CLI (one-shot) + TUI (interactive via clojure-tui)
- **mobile/** — Mobile client (deferred, placeholder only)
**Dependency matrix:**
```
PG NATS API(HTTP)
API yes pub —
Auth GW yes — —
Web SM — sub yes (internal)
TUI SM — sub yes (internal)
CLI — — yes (external, via Auth GW)
```
## Common Commands
### Running Services (REPL-driven)
```bash
# Single REPL with all modules
clj -A:dev:api:web-sm:tui-sm:auth-gw
# Individual service REPLs
clj -M:dev:api # API service
clj -M:dev:web-sm # Web session manager
clj -M:dev:tui-sm # TUI session manager
clj -M:dev:auth-gw # Auth gateway
```
Services expose `(start!)` / `(stop!)` / `(reset!)` in their REPL namespaces.
### Testing (Kaocha)
```bash
clj -M:test/unit # Unit tests — no Docker needed
clj -M:test/integration # Integration — requires Docker (Postgres + MinIO + NATS)
clj -M:test/e2e # E2E — requires full stack in Docker
clj -M:test/all # All tiers
```
Docker infra for integration tests: `docker compose -f docker-compose.test.yml up -d`
### Architecture Diagram
```bash
typst compile architecture.typst architecture.png
```
## Architecture
**Request flow:** Internet → nginx (TLS, prod only) → Auth Gateway → service (API / Web SM / TUI SM)
**Key pattern — data vs events separation:**
- All persistent data reads/writes go through the **API** (stateless REST, only service with PG access besides Auth GW)
- Real-time events flow via **NATS** pub/sub (community-scoped subjects)
- **Session managers** hold live client connections, subscribe to NATS for events, fetch full data from API via HTTP. No direct PG connection.
- SMs publish ephemeral events (typing indicators) directly to NATS — bypasses API for latency
**Auth Gateway** is a Clojure http-kit proxy that does its own DB reads/writes for session validation, then routes authenticated requests to internal services.
## Tech Stack
- All server modules: Clojure 1.12, http-kit 2.8, reitit 0.7, Ring 1.13
- Data: PostgreSQL (next.jdbc + HoneySQL) — API + Auth GW only
- Events: NATS (io.nats/jnats) — API publishes, SMs subscribe
- Web: Hiccup 2.0 + Datastar SDK (web-sm)
- CLI: shared API client SDK (babashka http-client) + clojure-tui (local dep at `../../clojure-tui`)
- Files: MinIO (S3-compatible)
## Conventions
- **PostgreSQL everywhere** — same DB in dev and prod, no SQLite. Dev infra runs in Docker.
- **HoneySQL maps** for all queries — avoid raw SQL strings
- **Migratus** for schema migrations
- **NATS** for all event pub/sub — community-scoped subjects (`chat.events.{community-id}`), DM subjects (`chat.dm.{channel-id}`)
- **MinIO** (S3-compatible) for file storage — same API as AWS S3
- **UUIDs** for all entity IDs (`java.util.UUID/randomUUID`)
- **EventBus protocol**: `publish!`, `subscribe!`, `unsubscribe!` — backed by NATS
- **SSE** for all server→client streaming (web + TUI), HTTP POSTs for client→server signals
## Namespace Conventions
```
ajet.chat.shared.* — shared/src/
ajet.chat.api.* — api/src/
ajet.chat.web.* — web-sm/src/
ajet.chat.tui-sm.* — tui-sm/src/
ajet.chat.auth-gw.* — auth-gw/src/
ajet.chat.cli.* — cli/src/
```
+12
View File
@@ -0,0 +1,12 @@
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit {:mvn/version "0.7.2"}
ring/ring-core {:mvn/version "1.13.0"}
ajet/chat-shared {:local/root "../shared"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.api.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}}}
+22
View File
@@ -0,0 +1,22 @@
# API Service Plan
## Stack
- http-kit (HTTP server)
- reitit (routing) + Ring middleware
- next.jdbc + HoneySQL (via shared/)
- PostgreSQL everywhere (dev + prod)
## Responsibilities
- Stateless REST API: all reads & writes for messages, channels, users
- Only service (besides Auth GW) with a direct PG connection
- Publishes events to NATS after DB writes
- No live connections — pure request/response
- Sits behind auth gateway (all requests pre-authenticated)
## TODO
- [ ] Define reitit route table
- [ ] http-kit server setup
- [ ] Design message/channel/user API endpoints
- [ ] Ring middleware: error handling, content negotiation, request logging
- [ ] NATS publish on write (message sent, channel created, etc.)
- [ ] Database migrations
+5
View File
@@ -0,0 +1,5 @@
(ns ajet.chat.api.core
"REST API service — http-kit + reitit.")
(defn -main [& _args]
(println "ajet-chat API starting..."))
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+436
View File
@@ -0,0 +1,436 @@
#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
#set page(width: auto, height: auto, margin: 1.5em)
#set text(font: "Fira Sans", size: 9pt, fill: rgb("#1a1a2e"))
// === Color Palette ===
#let c-edge = rgb("#e8f4f8")
#let c-gateway = rgb("#fce4ec")
#let c-api = rgb("#e8eaf6")
#let c-session = rgb("#e0f2f1")
#let c-client = rgb("#fff3e0")
#let c-infra = rgb("#f3e5f5")
#let c-bus = rgb("#fff9c4")
#let c-future = rgb("#f5f5f5")
#let s-main = rgb("#37474f") + 0.8pt
#let s-data = rgb("#7b1fa2") + 0.8pt
#let s-bus = rgb("#e65100") + 0.8pt
#let s-dim = rgb("#bbb") + 0.6pt
#let label-sm(body) = text(size: 7pt, body)
#let label-xs(body) = text(size: 6.5pt, fill: rgb("#666"), body)
#let section(title) = {
v(2em)
align(center, text(size: 13pt, weight: "bold", fill: rgb("#37474f"), title))
v(0.6em)
}
// ============================================================
// TITLE
// ============================================================
#align(center)[
#text(size: 18pt, weight: "bold")[ajet-chat]
#v(0.2em)
#text(size: 9pt, fill: rgb("#666"))[System Architecture — Clojure · http-kit · PostgreSQL · Datastar · MinIO]
]
// ============================================================
// 1. SYSTEM OVERVIEW
// ============================================================
#section[1 · System Overview]
#align(center)[
#diagram(
spacing: (3.5em, 2.4em),
node-stroke: s-main,
node-corner-radius: 4pt,
// External
node((2.5, 0.0), [*Internet*], stroke: none, fill: none),
node((-0.5, 0.0), [*Webhooks*\ #label-xs[git · CI · monitoring]], fill: c-client),
// nginx
node((2.5, 1.0), [*nginx*\ #label-xs[TLS termination · prod only]], fill: c-edge, width: 16em),
// Auth Gateway
node((2.5, 2.2), [*Auth Gateway*\ #label-xs[http-kit · session/token validation · rate limit · CORS · routing]], fill: c-gateway, width: 26em),
// Services row
node((-0.5, 4.0), [*API*\ #label-xs[stateless REST]\ #label-xs[reads / writes → PG]\ #label-xs[publishes → NATS]], fill: c-api),
node((1.8, 4.0), [*Web SM*\ #label-xs[Hiccup + Datastar SSE]\ #label-xs[browser connections]], fill: c-session),
node((3.8, 4.0), [*TUI SM*\ #label-xs[SSE + HTTP POST]\ #label-xs[terminal connections]], fill: c-session),
node((5.2, 4.0), [*Mobile SM*\ #label-xs[(future)]], fill: c-future, stroke: s-dim),
// Event bus
node((2.5, 5.8), [*NATS*\ #label-xs[pub/sub event bus]\ #label-xs[community-scoped subjects]], fill: c-bus),
// Data stores
node((-0.5, 7.5), [*PostgreSQL*\ #label-xs[data store · FTS]\ #label-xs[Auth GW + API only]], fill: c-infra),
node((-2.2, 4.0), [*MinIO*\ #label-xs[S3 files · avatars]], fill: c-infra),
// Clients
node((1.8, 8.0), [*Browsers*], stroke: none, fill: none),
node((3.8, 8.0), [*Terminals*\ #label-xs[CLI + TUI]], stroke: none, fill: none),
node((5.2, 8.0), [#text(fill: rgb("#aaa"))[*Phones*]], stroke: none, fill: none),
// --- Edges ---
// Internet → nginx → Auth Gateway
edge((2.5, 0.0), (2.5, 1.0), "-|>"),
edge((-0.5, 0.0), (2.5, 2.2), "-|>"),
edge((2.5, 1.0), (2.5, 2.2), "-|>"),
// Auth Gateway → services
edge((2.5, 2.2), (-0.5, 4.0), "-|>", label: label-sm[`/api/*`], label-side: left),
edge((2.5, 2.2), (1.8, 4.0), "-|>", label: label-sm[`/web`], label-side: left),
edge((2.5, 2.2), (3.8, 4.0), "-|>", label: label-sm[`/ws/tui`], label-side: right),
edge((2.5, 2.2), (5.2, 4.0), "-|>", stroke: s-dim),
// API → PG (data reads/writes — only API + Auth GW touch PG)
edge((-0.5, 4.0), (-0.5, 7.5), "<-|>", stroke: s-data,
label: text(size: 7pt, fill: rgb("#7b1fa2"))[data], label-side: left),
// API → NATS (publish events after writes)
edge((-0.5, 4.0), (2.5, 5.8), "-|>", stroke: s-bus,
label: text(size: 7pt, fill: rgb("#e65100"))[pub], label-side: left),
// NATS → SMs (subscribe for events)
edge((2.5, 5.8), (1.8, 4.0), "-|>", stroke: s-bus,
label: text(size: 7pt, fill: rgb("#e65100"))[sub], label-side: right),
edge((2.5, 5.8), (3.8, 4.0), "-|>", stroke: s-bus),
edge((2.5, 5.8), (5.2, 4.0), "-|>", stroke: s-dim),
// SMs → API (fetch full data after event notification)
edge((1.8, 4.0), (-0.5, 4.0), "-|>", stroke: s-data, bend: 20deg,
label: text(size: 7pt, fill: rgb("#7b1fa2"))[fetch], label-side: right),
edge((3.8, 4.0), (-0.5, 4.0), "-|>", stroke: s-data, bend: 12deg),
// API → MinIO (files)
edge((-0.5, 4.0), (-2.2, 4.0), "-|>", stroke: s-data,
label: text(size: 7pt, fill: rgb("#7b1fa2"))[files], label-side: left),
// SMs → clients
edge((1.8, 4.0), (1.8, 8.0), "-|>", label: label-sm[SSE], label-side: left),
edge((3.8, 4.0), (3.8, 8.0), "-|>", label: label-sm[SSE], label-side: left),
edge((5.2, 4.0), (5.2, 8.0), "-|>", stroke: s-dim),
)
]
// Legend
#v(0.5em)
#align(center)[
#rect(stroke: 0.5pt + rgb("#ccc"), radius: 4pt, inset: 0.6em)[
#text(size: 7pt)[
#box(rect(fill: c-gateway, width: 0.7em, height: 0.7em, radius: 2pt)) Auth
#h(0.8em)
#box(rect(fill: c-api, width: 0.7em, height: 0.7em, radius: 2pt)) API
#h(0.8em)
#box(rect(fill: c-session, width: 0.7em, height: 0.7em, radius: 2pt)) Session Mgr
#h(0.8em)
#box(rect(fill: c-infra, width: 0.7em, height: 0.7em, radius: 2pt)) Infrastructure
#h(0.8em)
#box(rect(fill: c-bus, width: 0.7em, height: 0.7em, radius: 2pt)) NATS
#h(0.8em)
#line(length: 1em, stroke: s-bus) pub/sub
#h(0.6em)
#line(length: 1em, stroke: s-data) data
]
]
]
// ============================================================
// 2. AUTH GATEWAY DETAIL
// ============================================================
#section[2 · Auth Gateway Flow]
#align(center)[
#diagram(
spacing: (3.5em, 2em),
node-stroke: s-main,
node-corner-radius: 4pt,
// Incoming
node((0, 0), [*Client Request*\ #label-xs[HTTPS / SSE]], fill: c-client),
// Steps
node((0, 1), [*TLS Termination*\ #label-xs[nginx (prod only)]], fill: c-edge),
node((0, 2), [*Extract Token*\ #label-xs[cookie or Authorization header]], fill: c-gateway),
node((0, 3), [*DB Lookup*\ #label-xs[sessions / api\_tokens table]], fill: c-gateway),
// Branch
node((-1.5, 4), [*401 Unauthorized*], fill: rgb("#ffcdd2"), stroke: rgb("#c62828") + 0.8pt),
node((1.5, 4), [*Attach User Context*\ #label-xs[user\_id, role, community\_id]], fill: c-gateway),
// Rate limit
node((1.5, 5), [*Rate Limit Check*\ #label-xs[per-user / per-IP]], fill: c-gateway),
// Route
node((1.5, 6), [*Route to Service*], fill: c-gateway),
// Targets
node((0, 7), [*API*], fill: c-api),
node((1.5, 7), [*Web SM*], fill: c-session),
node((3, 7), [*TUI SM*], fill: c-session),
// Edges
edge((0, 0), (0, 1), "-|>"),
edge((0, 1), (0, 2), "-|>"),
edge((0, 2), (0, 3), "-|>"),
edge((0, 3), (-1.5, 4), "-|>", label: label-sm[invalid], label-side: left),
edge((0, 3), (1.5, 4), "-|>", label: label-sm[valid], label-side: right),
edge((1.5, 4), (1.5, 5), "-|>"),
edge((1.5, 5), (1.5, 6), "-|>"),
edge((1.5, 6), (0, 7), "-|>", label: label-sm[`/api/*`], label-side: left),
edge((1.5, 6), (1.5, 7), "-|>", label: label-sm[`/web`], label-side: left),
edge((1.5, 6), (3, 7), "-|>", label: label-sm[`/ws/tui`], label-side: right),
)
]
// ============================================================
// 3. REAL-TIME EVENT FLOW
// ============================================================
#section[3 · Real-Time Event Flow]
#align(center)[
#diagram(
spacing: (3.5em, 2.2em),
node-stroke: s-main,
node-corner-radius: 4pt,
// Trigger
node((0, 0), [*User sends message*\ #label-xs[POST /api/messages]], fill: c-client),
// API
node((0, 1), [*API*\ #label-xs[validate · write to DB]], fill: c-api),
// Two steps: DB write then NATS publish
node((-1, 2.2), [*PostgreSQL*\ #label-xs[INSERT message row]], fill: c-infra),
node((1, 2.2), [*NATS*\ #label-xs[publish `chat.events.\{community\}`]], fill: c-bus),
// Fan out — SMs subscribe to NATS
node((-1.5, 3.8), [*Web SM*\ #label-xs[receives via NATS sub]], fill: c-session),
node((0, 3.8), [*TUI SM*\ #label-xs[receives via NATS sub]], fill: c-session),
node((1.5, 3.8), [*Mobile SM*\ #label-xs[NATS sub]], fill: c-future, stroke: s-dim),
// Client delivery — SMs fetch full data from API
node((-1.5, 5), [*Fetch full message*\ #label-xs[GET /api/messages/:id]], fill: c-api),
node((-1.5, 6.2), [*Datastar SSE*\ #label-xs[push HTML fragment]], fill: c-session),
node((0, 6.2), [*SSE*\ #label-xs[push JSON payload]], fill: c-session),
// Clients
node((-1.5, 7.4), [*Browser*\ #label-xs[DOM patched live]], fill: none, stroke: none),
node((0, 7.4), [*Terminal*\ #label-xs[message rendered]], fill: none, stroke: none),
// Edges
edge((0, 0), (0, 1), "-|>"),
edge((0, 1), (-1, 2.2), "-|>", stroke: s-data,
label: text(size: 7pt, fill: rgb("#7b1fa2"))[INSERT], label-side: left),
edge((0, 1), (1, 2.2), "-|>", stroke: s-bus,
label: text(size: 7pt, fill: rgb("#e65100"))[publish], label-side: right),
edge((1, 2.2), (-1.5, 3.8), "-|>", stroke: s-bus),
edge((1, 2.2), (0, 3.8), "-|>", stroke: s-bus),
edge((1, 2.2), (1.5, 3.8), "-|>", stroke: s-dim),
edge((-1.5, 3.8), (-1.5, 5), "-|>"),
edge((0, 3.8), (0, 6.2), "-|>"),
edge((-1.5, 5), (-1.5, 6.2), "-|>"),
edge((-1.5, 6.2), (-1.5, 7.4), "-|>"),
edge((0, 6.2), (0, 7.4), "-|>"),
)
]
// ============================================================
// 4. DATA MODEL
// ============================================================
#section[4 · Data Model]
#align(center)[
#diagram(
spacing: (4em, 2.5em),
node-stroke: s-main,
node-corner-radius: 3pt,
// Core entities
node((2.5, 1.0), [*communities*\ #label-xs[id · name · slug]], fill: rgb("#e1bee7")),
node((0.3, 1.9), [*users*\ #label-xs[id · username · email\ display\_name · avatar]], fill: rgb("#bbdefb")),
node((5.0, 0.7), [*channels*\ #label-xs[id · name · type\ public / private / voice]], fill: rgb("#c8e6c9")),
// Junction
node((2.6, 2.0), [*community\_members*\ #label-xs[role: owner / admin / member\ nickname · avatar override]], fill: rgb("#f0f4c3")),
node((2.4, 0.1), [*channel\_members*\ #label-xs[joined\_at]], fill: rgb("#f0f4c3")),
// Messages
node((2.6, 4.1), [*messages*\ #label-xs[id · body\_md · parent\_id\ created\_at · edited\_at]], fill: rgb("#ffe0b2")),
// Related
node((0.4, 0.0), [*notifications*\ #label-xs[id · type · source\_id\ read: bool]], fill: rgb("#ffccbc")),
node((5.2, 5.0), [*attachments*\ #label-xs[id · filename · storage\_key\ content\_type · size]], fill: rgb("#d1c4e9")),
node((2.0, 6.3), [*reactions*\ #label-xs[emoji · user\_id\ PK: msg + user + emoji]], fill: rgb("#fff9c4")),
node((4.1, 6.3), [*mentions*\ #label-xs[target\_type · target\_id]], fill: rgb("#ffccbc")),
node((5.0, 2.7), [*webhooks*\ #label-xs[id · name · token\_hash\ channel\_id · avatar]], fill: rgb("#b2dfdb")),
// Auth
node((0.2, 3.9), [*sessions*\ #label-xs[token\_hash · expires\_at]], fill: rgb("#cfd8dc")),
node((0.0, 8.0), [*api\_users*\ #label-xs[name · scopes]], fill: rgb("#cfd8dc")),
node((2.6, 8.0), [*api\_tokens*\ #label-xs[token\_hash · scopes · expires]], fill: rgb("#cfd8dc")),
// Relationships
edge((2.5, 1.0), (5.0, 0.7), "-|>", label: label-sm[has], label-side: right),
edge((2.5, 1.0), (2.6, 2.0), "-|>", label: label-sm[membership], label-side: left),
edge((0.3, 1.9), (2.6, 2.0), "-|>"),
edge((5.0, 0.7), (2.4, 0.1), "-|>"),
edge((0.3, 1.9), (2.4, 0.1), "-|>"),
edge((5.0, 0.7), (2.6, 4.1), "-|>"),
edge((0.3, 1.9), (2.6, 4.1), "-|>", label: label-sm[author], label-side: left),
edge((2.6, 4.1), (2.6, 4.1), "-|>", bend: 130deg, label: label-sm[thread], label-side: right),
edge((2.6, 4.1), (5.2, 5.0), "-|>"),
edge((2.6, 4.1), (2.0, 6.3), "-|>"),
edge((2.6, 4.1), (4.1, 6.3), "-|>"),
edge((0.3, 1.9), (0.4, 0.0), "-|>"),
edge((0.3, 1.9), (2.0, 6.3), "-|>", bend: -10deg),
edge((0.3, 1.9), (0.2, 3.9), "-|>", label: label-sm[owns], label-side: left),
edge((0.0, 8.0), (2.6, 8.0), "-|>"),
edge((5.0, 0.7), (5.0, 2.7), "-|>"),
)
]
// ============================================================
// 5. PRESENCE & TYPING
// ============================================================
#section[5 · Presence & Typing Indicators]
#align(center)[
#diagram(
spacing: (3.5em, 2em),
node-stroke: s-main,
node-corner-radius: 4pt,
// Heartbeat flow
node((-1, 0), [*Heartbeat (60s)*], stroke: none, fill: none),
node((2.5, 0), [*Typing*], stroke: none, fill: none),
// Heartbeat
node((-1, 1), [*Client*\ #label-xs[POST /api/heartbeat]], fill: c-client),
node((-1, 2), [*API*\ #label-xs[instant 200 response]], fill: c-api),
node((-1, 3.2), [*PostgreSQL*\ #label-xs[UPDATE last\_seen\_at]], fill: c-infra),
node((-1, 4.0), [*NATS*\ #label-xs[API publishes presence event]], fill: c-bus),
node((-1, 5.2), [*Session Managers*\ #label-xs[receive via NATS sub]\ #label-xs[buffer → flush 1x/min per client]\ #label-xs[filter: relevant users only]\ #label-xs[diff: status changes only]], fill: c-session),
node((-1, 6.8), [*Clients*\ #label-xs[batched presence snapshot]], fill: none, stroke: none),
// Typing — SM publishes directly to NATS, not via API
node((2.5, 1), [*Keypress in input*], fill: c-client),
node((2.5, 2), [*Session Manager*\ #label-xs[receives via HTTP POST]], fill: c-session),
node((2.5, 3.2), [*NATS*\ #label-xs[SM publishes typing event]\ #label-xs[no API round-trip]], fill: c-bus),
node((2.5, 4.5), [*Other SMs*\ #label-xs[receive via NATS sub]\ #label-xs[filter: same channel/DM]\ #label-xs[deliver immediately]], fill: c-session),
node((2.5, 5.8), [*Clients*\ #label-xs[show "user is typing..."]], fill: none, stroke: none),
node((2.5, 6.8), [*Auto-expire*\ #label-xs[15s no keypress → hide]\ #label-xs[typing:stop on send or clear input]], fill: rgb("#fff3e0")),
// Heartbeat edges
edge((-1, 1), (-1, 2), "-|>"),
edge((-1, 2), (-1, 3.2), "-|>", stroke: s-data),
edge((-1, 3.2), (-1, 4.0), "-|>", stroke: s-bus),
edge((-1, 4.0), (-1, 5.2), "-|>", stroke: s-bus),
edge((-1, 5.2), (-1, 6.8), "-|>"),
// Typing edges
edge((2.5, 1), (2.5, 2), "-|>"),
edge((2.5, 2), (2.5, 3.2), "-|>", stroke: s-bus),
edge((2.5, 3.2), (2.5, 4.5), "-|>", stroke: s-bus),
edge((2.5, 4.5), (2.5, 5.8), "-|>"),
)
]
// ============================================================
// 6. CLI / TUI CLIENT
// ============================================================
#section[6 · CLI / TUI Client]
#align(center)[
#diagram(
spacing: (3.5em, 2em),
node-stroke: s-main,
node-corner-radius: 4pt,
// Entry
node((1, 0), [*`ajet-chat`*\ #label-xs[single binary · babashka/bbin]], fill: c-client),
// Branch
node((0, 1.5), [*CLI Mode*\ #label-xs[`ajet-chat <cmd> [args]`]\ #label-xs[one-shot · stateless]], fill: rgb("#e3f2fd")),
node((2, 1.5), [*TUI Mode*\ #label-xs[`ajet-chat` (no args)]\ #label-xs[interactive · clojure-tui]], fill: rgb("#e8f5e9")),
// CLI details
node((-0.5, 3), [*Commands*\ #label-xs[send · read · channels\ notifications · status\ /help · /ban · /kick]], fill: rgb("#e3f2fd")),
node((0, 4.5), [*API Client*\ #label-xs[HTTP → Auth GW → API]], fill: c-api),
// TUI details
node((2, 3), [*UI Layout*\ #label-xs[channel sidebar\ message list · input\ presence · typing]], fill: rgb("#e8f5e9")),
node((3, 3), [*timg*\ #label-xs[inline images\ optional dep]], fill: rgb("#fff3e0")),
node((2, 4.5), [*TUI SM Connection*\ #label-xs[SSE for live updates]], fill: c-session),
// Shared
node((1, 6), [*Shared Components*\ #label-xs[API client · auth/config (~/.config/ajet-chat/)\ message formatting · notification model]], fill: rgb("#f5f5f5"), width: 20em),
// Edges
edge((1, 0), (0, 1.5), "-|>", label: label-sm[args], label-side: left),
edge((1, 0), (2, 1.5), "-|>", label: label-sm[no args], label-side: right),
edge((0, 1.5), (-0.5, 3), "-|>"),
edge((-0.5, 3), (0, 4.5), "-|>"),
edge((2, 1.5), (2, 3), "-|>"),
edge((2, 3), (2, 4.5), "-|>"),
edge((0, 4.5), (1, 6), "-|>"),
edge((2, 4.5), (1, 6), "-|>"),
)
]
// ============================================================
// 7. DEPLOYMENT
// ============================================================
#section[7 · Deployment]
#align(center)[
#diagram(
spacing: (3em, 2em),
node-stroke: s-main,
node-corner-radius: 4pt,
// Dev
node((-1, 0), [*Development*], stroke: none, fill: none),
node((3, 0), [*Production*], stroke: none, fill: none),
// Dev stack
node((-1, 1), [*docker-compose.dev.yml*\ #label-xs[infrastructure only]], fill: c-edge),
node((-2.5, 2.5), [*PostgreSQL*\ #label-xs[container]], fill: c-infra),
node((-1, 2.5), [*NATS*\ #label-xs[container]], fill: c-bus),
node((0.5, 2.5), [*MinIO*\ #label-xs[container]], fill: c-infra),
node((-1, 4), [*Clojure REPLs*\ #label-xs[local JVM processes]\ #label-xs[nrepl + cider-nrepl]\ #label-xs[start! / stop! / reset!]], fill: c-api, width: 14em),
// Prod stack
node((3.5, 1), [*docker-compose.yml*\ #label-xs[full stack]], fill: c-edge),
node((2.5, 2.5), [*nginx*], fill: c-edge),
node((2.5, 3.5), [*Auth GW*\ #label-xs[uberjar]], fill: c-gateway),
node((2, 4.5), [*API*\ #label-xs[uberjar]], fill: c-api),
node((3, 4.5), [*Web SM*\ #label-xs[uberjar]], fill: c-session),
node((4, 4.5), [*TUI SM*\ #label-xs[uberjar]], fill: c-session),
node((2, 5.8), [*PostgreSQL*], fill: c-infra),
node((3, 5.8), [*NATS*], fill: c-bus),
node((4, 5.8), [*MinIO*], fill: c-infra),
// Dev edges
edge((-1, 1), (-2.5, 2.5), "-|>"),
edge((-1, 1), (-1, 2.5), "-|>"),
edge((-1, 1), (0.5, 2.5), "-|>"),
edge((-1, 4), (-2.5, 2.5), "<-|>", stroke: s-data, label: label-sm[JDBC], label-side: left),
edge((-1, 4), (-1, 2.5), "<-|>", stroke: s-bus, label: label-sm[pub/sub], label-side: left),
edge((-1, 4), (0.5, 2.5), "<-|>", stroke: s-data, label: label-sm[S3], label-side: right),
// Prod edges
edge((3.5, 1), (2.5, 2.5), "-|>"),
edge((2.5, 2.5), (2.5, 3.5), "-|>"),
edge((2.5, 3.5), (2, 4.5), "-|>"),
edge((2.5, 3.5), (3, 4.5), "-|>"),
edge((2.5, 3.5), (4, 4.5), "-|>"),
edge((2, 4.5), (2, 5.8), "-|>", stroke: s-data),
edge((2, 4.5), (3, 5.8), "-|>", stroke: s-bus),
edge((2, 4.5), (4, 5.8), "-|>", stroke: s-data),
)
]
+12
View File
@@ -0,0 +1,12 @@
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit {:mvn/version "0.7.2"}
ring/ring-core {:mvn/version "1.13.0"}
ajet/chat-shared {:local/root "../shared"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.auth-gw.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}}}
+89
View File
@@ -0,0 +1,89 @@
# Auth Gateway Plan
## Overview
Clojure-based edge gateway. All external traffic enters here.
Authenticates requests, then proxies to internal services.
## Stack
- http-kit (HTTP server + async reverse proxy)
- reitit (route matching to determine target service)
- Ring middleware (CORS, rate limiting, logging)
- next.jdbc + HoneySQL (via shared/) for session/token DB lookups
## Responsibilities
- TLS termination (or sit behind nginx for TLS in prod)
- Token validation / session lookup (custom DB reads)
- Authorization (permission checks per route)
- Rate limiting (per-user, per-IP)
- Route authenticated requests to internal services:
- `/api/*` → API service
- `/` + web routes → Web session manager
- `/ws/tui/*` → TUI session manager
- `/ws/mobile/*` → Mobile session manager (future)
- Session creation on login (custom DB writes)
- Audit logging
## Auth Flow
### OAuth Login (v1)
1. Client redirects to `/auth/oauth/:provider` (e.g., `/auth/oauth/github`)
2. Auth GW redirects to provider's OAuth authorization URL
3. Provider redirects back to `/auth/oauth/:provider/callback` with auth code
4. Auth GW exchanges code for access token, fetches user profile from provider
5. Auth GW creates/links user + `oauth_accounts` row, creates session in DB
6. Auth GW sets session cookie and redirects to app
### Login Page
Auth GW owns the login page — Web SM never sees unauthenticated users.
Renders a minimal page with OAuth provider buttons (GitHub, Gitea) and
redirects to the appropriate SSO flow. After OAuth callback completes,
Auth GW sets the session cookie and redirects back to the app.
### Token Format
- Session tokens: 32 random bytes (256 bits of entropy), encoded as base64url
- Stored in DB as bcrypt hash (`token_hash` column in `sessions` table)
- Client sends raw base64url token; Auth GW hashes and looks up the match
### Session Lifecycle
- Rolling expiry: session TTL extends on each authenticated request
- Cookie attributes (web clients): `HttpOnly`, `Secure`, `SameSite=Strict`
- CLI/mobile: raw token in `Authorization: Bearer <token>` header
### Session Validation
1. Client sends request with token (Authorization header or cookie)
2. Auth GW hashes token with bcrypt, looks up `token_hash` in `sessions`
3. If valid: extend expiry, attach user context headers (`X-User-Id`, `X-User-Role`), proxy to target service
4. If invalid/expired: 401 response
### 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 its owner
3. After initial community exists, new users must be invited by an existing member (invite-only)
### Future: Email-Based Local Auth
- Email + magic link or email + password (requires email infrastructure)
- Will add `credentials` table when implemented
- OAuth remains primary, local auth is an alternative for self-hosted deployments
## Request Routing
Auth GW generates a unique `X-Trace-Id` header on each inbound request.
All downstream services propagate the trace ID through the call graph.
## Protocols Handled
- **HTTPS**: standard request/response proxy
- **SSE**: authenticate on initial connection, then pass through to session managers
## TODO
- [ ] http-kit reverse proxy setup
- [ ] Route table: path prefix → internal service host:port
- [ ] Token/session DB schema (in shared/)
- [ ] Token validation middleware
- [ ] OAuth flow: GitHub provider (redirect → callback → session creation)
- [ ] OAuth flow: Gitea provider
- [ ] `oauth_accounts` table: link OAuth identities to users
- [ ] SSE connection authentication
- [ ] Rate limiting middleware
- [ ] Logout endpoint (delete session)
- [ ] CORS configuration
- [ ] Audit logging
+5
View File
@@ -0,0 +1,5 @@
(ns ajet.chat.auth-gw.core
"Auth gateway — http-kit reverse proxy with authn/authz.")
(defn -main [& _args]
(println "ajet-chat auth gateway starting..."))
+10
View File
@@ -0,0 +1,10 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
ajet/chat-shared {:local/root "../shared"}
ajet/clojure-tui {:local/root "../../clojure-tui"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.cli.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}}}
+71
View File
@@ -0,0 +1,71 @@
# CLI / TUI Client Plan
## Overview
Single executable with two interfaces, distributed via bbin/babashka:
- **CLI mode**: one-off commands via args (send message, read history, check notifications)
- **TUI mode**: full interactive terminal app (clojure-tui)
Shared codebase — CLI and TUI use the same API client, auth, and config.
## Stack
- Babashka for fast startup + distribution via bbin
- clojure-tui (~/repos/clojure-tui) for TUI rendering
- Shared API client library (HTTP)
- Connects to TUI session manager via SSE for real-time updates
- HTTP POSTs to TUI SM for client→server signals (typing indicators)
## Modes
### CLI Mode (`ajet-chat <command> [args]`)
Stateless, one-shot commands. Fast startup via Babashka.
```
ajet-chat send #general "hey everyone"
ajet-chat read #general # last N messages
ajet-chat read #general --since 1h # messages from last hour
ajet-chat dm @alice "hey" # send a DM
ajet-chat dm @alice # read DM history with alice
ajet-chat dms # list DM conversations
ajet-chat notifications # unread notifications
ajet-chat channels # list channels
ajet-chat status # connection/auth status
```
### TUI Mode (`ajet-chat` or `ajet-chat tui`)
Full interactive app. Launches when invoked with no command (or explicit `tui`).
- Channel sidebar, message list, input
- Real-time updates via TUI session manager (SSE)
- Keyboard navigation, scrollback, search
- Presence indicators, typing notifications
- Inline image rendering (see below)
## Shared Components
- **API client**: HTTP client for REST calls to API (via auth gateway)
- **Auth/config**: token storage, server URL, credentials (~/.config/ajet-chat/)
- **Message formatting**: rendering messages to terminal (shared between CLI output and TUI views)
- **Notification model**: unread tracking, mention detection
## Image Rendering
Inline image display in the terminal via [timg](https://github.com/hzeller/timg) (optional dependency).
- If `timg` is on PATH, shell out to render images inline in both CLI and TUI modes
- timg handles terminal protocol detection automatically (kitty, iTerm2, sixel, unicode block fallback)
- Works over SSH
- If `timg` is not available, fall back to:
1. Native kitty/sixel escape sequences for compatible terminals
2. No inline images (show a placeholder with filename/URL)
- In TUI mode: coordinate timg output with clojure-tui's terminal state (suspend raw mode, render, resume)
## Distribution
- Packaged as a bbin-installable babashka script/jar
- `bbin install io.github.ajet/ajet-chat-cli` (or similar)
- Single `ajet-chat` binary on PATH
## TODO
- [ ] Set up babashka-compatible project structure
- [ ] CLI arg parsing (babashka.cli or tools.cli)
- [x] API client module (shared between CLI and TUI) — `ajet.chat.shared.api-client` in shared/
- [ ] Auth flow: login, token storage, refresh
- [ ] CLI commands: send, read, notifications, channels, status
- [ ] TUI layout (channels sidebar, messages, input)
- [ ] TUI real-time subscription via session manager
- [ ] TUI keyboard navigation and commands
- [ ] bbin packaging and install
+5
View File
@@ -0,0 +1,5 @@
(ns ajet.chat.cli.core
"CLI client using clojure-tui.")
(defn -main [& _args]
(println "ajet-chat CLI starting..."))
+31
View File
@@ -0,0 +1,31 @@
{:paths []
:deps {}
:aliases
{:api {:extra-deps {ajet/chat-api {:local/root "api"}}
:main-opts ["-m" "ajet.chat.api.core"]}
:web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}
:main-opts ["-m" "ajet.chat.web.core"]}
:tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}
:main-opts ["-m" "ajet.chat.tui-sm.core"]}
:cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}
:main-opts ["-m" "ajet.chat.cli.core"]}
:auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}
:main-opts ["-m" "ajet.chat.auth-gw.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}
:test/unit {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
:main-opts ["-m" "kaocha.runner" "--focus" "unit"]}
:test/integration {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
:main-opts ["-m" "kaocha.runner" "--focus" "integration"]}
:test/e2e {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
org.babashka/http-client {:mvn/version "0.4.22"}}
:main-opts ["-m" "kaocha.runner" "--focus" "e2e"]}
:test/all {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
org.babashka/http-client {:mvn/version "0.4.22"}}
:main-opts ["-m" "kaocha.runner"]}}}
+13
View File
@@ -0,0 +1,13 @@
# Mobile App Plan
## Status: TBD
## Options to Evaluate
- **ClojureDart** — Clojure compiling to Dart/Flutter
- **React Native + API** — JS/TS frontend consuming the API
- **Progressive Web App** — Reuse the web frontend with mobile optimizations
## TODO
- [ ] Evaluate ClojureDart maturity
- [ ] Decide on approach
- [ ] Scaffold project
+489
View File
@@ -0,0 +1,489 @@
# 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](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/<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
- [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/<slug>/...` 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
+8
View File
@@ -0,0 +1,8 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}
com.github.seancorfield/honeysql {:mvn/version "2.6.1230"}
org.postgresql/postgresql {:mvn/version "42.7.4"}
io.nats/jnats {:mvn/version "2.20.5"}
org.babashka/http-client {:mvn/version "0.4.22"}
org.clojure/data.json {:mvn/version "2.5.1"}}}
+218
View File
@@ -0,0 +1,218 @@
(ns ajet.chat.shared.api-client
"HTTP client SDK for the ajet-chat API.
All public functions take an explicit context map (ctx) as the first argument.
This avoids dynamic vars which don't work with core.async or cross-thread
callbacks (NATS handlers, SSE).
ctx shape:
{:base-url \"http://localhost:3001\" ;; API URL (SMs) or Auth GW URL (CLI)
:auth-token \"base64url-token\" ;; raw token, SDK prepends \"Bearer \"
:trace-id \"uuid\" ;; optional, from X-Trace-Id
:user-id \"uuid\" ;; optional, informational
:user-role \"admin\"} ;; optional, informational
Error handling:
- HTTP 4xx/5xx → ex-info with {:type :ajet.chat/api-error, :status, :body, :trace-id}
- Network errors (connect refused, timeout) → propagate raw from http-client"
(:require [babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]))
;;; ---------------------------------------------------------------------------
;;; Private HTTP layer
;;; ---------------------------------------------------------------------------
(defn- api-url
"Join base-url and path, ensuring exactly one slash between them."
[base-url path]
(let [base (if (str/ends-with? base-url "/")
(subs base-url 0 (dec (count base-url)))
base-url)
p (if (str/starts-with? path "/")
(subs path 1)
path)]
(str base "/" p)))
(defn- build-headers
"Build HTTP headers from ctx."
[ctx]
(cond-> {"Accept" "application/json"}
(:auth-token ctx) (assoc "Authorization" (str "Bearer " (:auth-token ctx)))
(:trace-id ctx) (assoc "X-Trace-Id" (:trace-id ctx))))
(defn- encode-json
"Encode a Clojure value as a JSON string."
[data]
(json/write-str data))
(defn- parse-json
"Parse a JSON string into a Clojure map with keyword keys.
Returns nil for nil/blank input."
[s]
(when-not (str/blank? s)
(json/read-str s :key-fn keyword)))
(defn- check-response!
"Throw ex-info on 4xx/5xx responses."
[response trace-id]
(let [status (:status response)]
(when (>= status 400)
(throw (ex-info (str "API error: HTTP " status)
{:type :ajet.chat/api-error
:status status
:body (parse-json (:body response))
:trace-id trace-id})))))
(defn- request!
"Core HTTP dispatch. All public functions route through here.
Returns parsed JSON body as a Clojure map."
[ctx method path & [{:keys [body query-params]}]]
(let [headers (cond-> (build-headers ctx)
body (assoc "Content-Type" "application/json"))
url (api-url (:base-url ctx) path)
trace-id (:trace-id ctx)
opts (cond-> {:method method
:uri url
:headers headers
:throw false}
body (assoc :body (encode-json body))
query-params (assoc :query-params query-params))
response (http/request opts)]
(check-response! response trace-id)
(parse-json (:body response))))
;;; ---------------------------------------------------------------------------
;;; Context helper
;;; ---------------------------------------------------------------------------
(defn request->ctx
"Build an API client ctx from a Ring request and the API base URL.
Extracts auth token from Authorization header and trace/user info from
custom headers injected by Auth GW."
[ring-request api-base-url]
(let [headers (:headers ring-request)
auth (get headers "authorization")
token (when auth
(let [parts (str/split auth #"\s+" 2)]
(when (= "Bearer" (first parts))
(second parts))))]
(cond-> {:base-url api-base-url}
token (assoc :auth-token token)
(get headers "x-trace-id") (assoc :trace-id (get headers "x-trace-id"))
(get headers "x-user-id") (assoc :user-id (get headers "x-user-id"))
(get headers "x-user-role") (assoc :user-role (get headers "x-user-role")))))
;;; ---------------------------------------------------------------------------
;;; Public API — Channels (community-scoped)
;;; ---------------------------------------------------------------------------
(defn get-channels
"List channels for a community."
[ctx community-slug]
(request! ctx :get (str "c/" community-slug "/channels")))
(defn get-channel
"Get a single channel by ID within a community."
[ctx community-slug channel-id]
(request! ctx :get (str "c/" community-slug "/channels/" channel-id)))
;;; ---------------------------------------------------------------------------
;;; Public API — Messages
;;; ---------------------------------------------------------------------------
(defn get-messages
"Fetch messages for a channel. opts may include :after, :before, :limit, :thread."
[ctx channel-id & [opts]]
(let [qp (cond-> {}
(:after opts) (assoc "after" (str (:after opts)))
(:before opts) (assoc "before" (str (:before opts)))
(:limit opts) (assoc "limit" (str (:limit opts)))
(:thread opts) (assoc "thread" (str (:thread opts))))]
(request! ctx :get (str "api/messages/" channel-id)
(when (seq qp) {:query-params qp}))))
(defn send-message
"Send a message to a channel. body-map should contain at least :body_md."
[ctx channel-id body-map]
(request! ctx :post (str "api/messages/" channel-id) {:body body-map}))
(defn edit-message
"Edit a message. body-map should contain :body_md."
[ctx message-id body-map]
(request! ctx :put (str "api/messages/" message-id) {:body body-map}))
(defn delete-message
"Delete a message."
[ctx message-id]
(request! ctx :delete (str "api/messages/" message-id)))
;;; ---------------------------------------------------------------------------
;;; Public API — Reactions
;;; ---------------------------------------------------------------------------
(defn add-reaction
"Add a reaction (emoji) to a message. Idempotent."
[ctx message-id emoji]
(request! ctx :put (str "api/messages/" message-id "/reactions/" emoji)))
(defn remove-reaction
"Remove a reaction (emoji) from a message."
[ctx message-id emoji]
(request! ctx :delete (str "api/messages/" message-id "/reactions/" emoji)))
;;; ---------------------------------------------------------------------------
;;; Public API — DMs (global)
;;; ---------------------------------------------------------------------------
(defn get-dms
"List the current user's DM channels."
[ctx]
(request! ctx :get "dm"))
(defn get-or-create-dm
"Get or create a DM channel with another user. body-map should contain :user_id."
[ctx body-map]
(request! ctx :post "dm" {:body body-map}))
;;; ---------------------------------------------------------------------------
;;; Public API — Users
;;; ---------------------------------------------------------------------------
(defn get-user
"Get a user by ID."
[ctx user-id]
(request! ctx :get (str "api/users/" user-id)))
(defn get-me
"Get the current authenticated user."
[ctx]
(request! ctx :get "api/users/me"))
;;; ---------------------------------------------------------------------------
;;; Public API — Notifications
;;; ---------------------------------------------------------------------------
(defn get-notifications
"Fetch notifications. opts may include :unread, :after, :limit."
[ctx & [opts]]
(let [qp (cond-> {}
(some? (:unread opts)) (assoc "unread" (str (:unread opts)))
(:after opts) (assoc "after" (str (:after opts)))
(:limit opts) (assoc "limit" (str (:limit opts))))]
(request! ctx :get "api/notifications"
(when (seq qp) {:query-params qp}))))
(defn mark-notifications-read
"Mark notifications as read. body-map should contain :notification_ids."
[ctx body-map]
(request! ctx :post "api/notifications/read" {:body body-map}))
;;; ---------------------------------------------------------------------------
;;; Public API — Presence
;;; ---------------------------------------------------------------------------
(defn heartbeat
"Send a presence heartbeat."
[ctx]
(request! ctx :post "api/heartbeat"))
+17
View File
@@ -0,0 +1,17 @@
(ns ajet.chat.shared.db
"Database layer — uses next.jdbc + HoneySQL. PostgreSQL everywhere."
(:require [next.jdbc :as jdbc]
[honey.sql :as sql]))
(defn make-datasource
"Create a PostgreSQL datasource."
[& [{:keys [dbname host port user password]
:or {dbname "ajet_chat"
host "localhost"
port 5432}}]]
(jdbc/get-datasource {:dbtype "postgresql"
:dbname dbname
:host host
:port port
:user user
:password password}))
+12
View File
@@ -0,0 +1,12 @@
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit {:mvn/version "0.7.2"}
ring/ring-core {:mvn/version "1.13.0"}
ajet/chat-shared {:local/root "../shared"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.tui-sm.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}}}
+28
View File
@@ -0,0 +1,28 @@
# TUI Session Manager Plan
## Overview
Server-side session manager for terminal clients (CLI + TUI).
Holds live SSE connections, delivers real-time events.
## Stack
- http-kit (HTTP server + SSE)
- reitit (routing) + Ring middleware
- NATS (via shared/) for event pub/sub — no direct PG connection
- Reads data from API (internal HTTP calls)
## Responsibilities
- Manage live SSE connections for terminal clients
- Subscribe to NATS for real-time events (messages, presence, typing)
- Publish ephemeral events (typing indicators) to NATS — no API round-trip
- Fetch full data from API when notification contains only IDs
- Sits behind auth gateway (all requests pre-authenticated)
## TODO
- [ ] http-kit server setup with SSE
- [ ] Connection tracking (atom of connected SSE clients)
- [ ] HTTP POST endpoints for client→server signals (typing indicators)
- [ ] NATS subscription for chat events
- [ ] NATS publish for typing indicators
- [x] Internal API client for data fetches — `ajet.chat.shared.api-client` in shared/
- [ ] Event filtering: deliver only relevant events per client
- [ ] Presence batching: buffer heartbeat events, flush 1x/min
+5
View File
@@ -0,0 +1,5 @@
(ns ajet.chat.tui-sm.core
"TUI session manager — http-kit WebSocket/SSE for terminal clients.")
(defn -main [& _args]
(println "ajet-chat TUI session manager starting..."))
+14
View File
@@ -0,0 +1,14 @@
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit {:mvn/version "0.7.2"}
ring/ring-core {:mvn/version "1.13.0"}
hiccup/hiccup {:mvn/version "2.0.0-RC4"}
dev.data-star.clojure/sdk {:mvn/version "1.0.0-RC5"}
ajet/chat-shared {:local/root "../shared"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.web.core"]}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}}}
+29
View File
@@ -0,0 +1,29 @@
# Web Session Manager Plan
## Stack
- http-kit (HTTP server + SSE)
- reitit (routing) + Ring middleware
- Hiccup for HTML templating
- Datastar Clojure SDK (dev.data-star.clojure/sdk) for SSE-driven reactivity
- No ClojureScript — server-rendered with Datastar enhancement
## Responsibilities
- Web session manager: manages live browser connections
- Serves Hiccup-rendered pages
- Holds Datastar SSE connections, pushes HTML fragments on events
- Subscribes to NATS for real-time events — no direct PG connection
- Publishes ephemeral events (typing indicators) to NATS — no API round-trip
- Fetches full data from API (internal HTTP calls), not directly from DB
- Sits behind auth gateway (all requests pre-authenticated)
## TODO
- [ ] http-kit server setup with Datastar SDK (http-kit adapter)
- [ ] Design page layout / Hiccup components
- [ ] Integrate Datastar (CDN or vendor the JS)
- [ ] Chat view: channel list, message list, input
- [ ] NATS subscription for chat events
- [ ] NATS publish for typing indicators
- [x] Internal API client for data fetches — `ajet.chat.shared.api-client` in shared/
- [ ] SSE endpoint: push Datastar fragments on events
- [ ] Connection tracking (atom of connected SSE clients)
- [ ] ~~Login page~~ — Auth GW owns the login page; Web SM never sees unauthenticated users
+5
View File
@@ -0,0 +1,5 @@
(ns ajet.chat.web.core
"Web session manager — http-kit + Hiccup + Datastar SSE.")
(defn -main [& _args]
(println "ajet-chat web starting..."))