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