init commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.cpcache/
|
||||
.nrepl-port
|
||||
target/
|
||||
*.db
|
||||
.clj-kondo/.cache/
|
||||
.lsp/
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns ajet.chat.api.core
|
||||
"REST API service — http-kit + reitit.")
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat API starting..."))
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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),
|
||||
)
|
||||
]
|
||||
@@ -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"}}}}}
|
||||
@@ -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
|
||||
@@ -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..."))
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns ajet.chat.cli.core
|
||||
"CLI client using clojure-tui.")
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat CLI starting..."))
|
||||
@@ -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"]}}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}}}
|
||||
@@ -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"))
|
||||
@@ -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}))
|
||||
@@ -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"}}}}}
|
||||
@@ -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
|
||||
@@ -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..."))
|
||||
@@ -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"}}}}}
|
||||
@@ -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
|
||||
@@ -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..."))
|
||||
Reference in New Issue
Block a user