init codebase

This commit is contained in:
2026-02-17 17:30:45 -05:00
parent a3b28549b4
commit f7e2755a91
175 changed files with 21600 additions and 232 deletions
+5
View File
@@ -0,0 +1,5 @@
AJET__DB__PASSWORD=change-me-in-production
GITHUB_CLIENT_ID=your-github-oauth-app-client-id
GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
+53 -11
View File
@@ -30,17 +30,54 @@ CLI — — yes (external, via Auth GW)
## Common Commands
### Task Runner (Babashka)
Preferred way to run tasks — `bb` wraps `clj` commands and manages Docker infra automatically.
```bash
bb tasks # List all available tasks
# Development
bb dev # Start dev infra + nREPL (all modules)
bb infra:dev # Start dev Docker infra only
bb infra:dev:stop # Stop dev Docker infra
# Testing
bb test # All tests (starts test infra)
bb test:unit # Unit tests only (no Docker needed)
bb test:integration # Integration tests (starts test infra)
bb test:e2e # E2E tests (starts test infra)
# Per-module testing
bb test:shared # All shared tests
bb test:shared:unit # Shared unit tests only
bb test:api # All API tests
bb test:api:unit # API unit tests only
bb test:auth-gw # All auth-gw tests
# Per-module bb.edn (from module directory)
cd shared && bb test:unit # Delegates to root bb.edn
# Build & Deploy
bb build api # Build uberjar for a module
bb clean api # Clean module artifacts (or bb clean for all)
bb prod # Start production stack (docker compose)
bb prod:stop # Stop production stack
bb prod:logs # Tail production logs
# Database
bb db:reset-test # Drop & recreate test DB schema
```
### Running Services (REPL-driven)
```bash
# Single REPL with all modules
clj -A:dev:api:web-sm:tui-sm:auth-gw
# Via bb (recommended)
bb dev # nREPL with all modules + dev infra
# 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
# Via clj directly
clj -A:dev:api:web-sm:tui-sm:auth-gw # Single REPL with all modules
clj -M:dev:api # Individual service REPL
```
Services expose `(start!)` / `(stop!)` / `(reset!)` in their REPL namespaces.
@@ -48,10 +85,15 @@ 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
# Via bb (recommended — manages Docker infra automatically)
bb test:unit # Unit tests — no Docker needed
bb test:integration # Integration — starts Docker automatically
bb test # All tiers
# Via clj directly (legacy — requires manual Docker management)
clj -M:test/base:test/unit # Unit tests
clj -M:test/base:test/integration # Integration tests
clj -M:test -m kaocha.runner # All tests (using unified :test alias)
```
Docker infra for integration tests: `docker compose -f docker-compose.test.yml up -d`
+12
View File
@@ -0,0 +1,12 @@
FROM clojure:temurin-21-tools-deps AS builder
WORKDIR /app
COPY deps.edn build.clj ./
COPY shared/ shared/
COPY api/ api/
RUN clj -T:build uber :module api
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/api/target/api.jar app.jar
EXPOSE 3001
CMD ["java", "-jar", "app.jar"]
+12
View File
@@ -0,0 +1,12 @@
{:tasks
{test
{:doc "Run all API module tests"
:task (shell {:dir ".."} "bb test:api")}
test:unit
{:doc "Run API unit tests only"
:task (shell {:dir ".."} "bb test:api:unit")}
test:integration
{:doc "Run API integration tests"
:task (shell {:dir ".."} "bb test:api:integration")}}}
+23
View File
@@ -0,0 +1,23 @@
{:server {:host "0.0.0.0" :port 3001}
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
:user "ajet" :password "ajet_dev" :pool-size 10
:migrations {:enabled true :location "migrations"}}
:nats {:url "nats://localhost:4222"
:stream-name "ajet-events"
:publish-timeout-ms 5000}
:minio {:endpoint "http://localhost:9000"
:access-key "minioadmin" :secret-key "minioadmin"
:bucket "ajet-chat"}
:limits {:max-message-length 4000
:max-upload-size 10485760
:edit-window-minutes 60
:default-page-size 50
:max-page-size 100}
:profiles
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
:password "ajet_test"}
:nats {:url "nats://localhost:4223"}
:minio {:endpoint "http://localhost:9002"}}
:prod {:db {:pool-size 20}
:nats {:publish-timeout-ms 10000}}}}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text UNIQUE NOT NULL,
display_name text,
email text,
avatar_url text,
status_text text,
last_seen_at timestamptz,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS oauth_accounts;
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS oauth_accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider text NOT NULL,
provider_user_id text NOT NULL,
provider_username text,
created_at timestamptz DEFAULT now(),
UNIQUE (provider, provider_user_id)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS communities;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS communities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text UNIQUE NOT NULL,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS community_members;
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS community_members (
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
nickname text,
avatar_url text,
joined_at timestamptz DEFAULT now(),
PRIMARY KEY (community_id, user_id)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS channel_categories;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS channel_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
name text NOT NULL,
position int NOT NULL DEFAULT 0
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS channels;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
community_id uuid REFERENCES communities (id) ON DELETE CASCADE,
category_id uuid REFERENCES channel_categories (id) ON DELETE SET NULL,
name text NOT NULL,
type text NOT NULL CHECK (type IN ('text', 'dm', 'group_dm')),
visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
topic text,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS channel_members;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS channel_members (
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
joined_at timestamptz DEFAULT now(),
last_read_message_id uuid,
PRIMARY KEY (channel_id, user_id)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS messages;
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
parent_id uuid REFERENCES messages (id) ON DELETE SET NULL,
body_md text NOT NULL,
created_at timestamptz DEFAULT now(),
edited_at timestamptz
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS attachments;
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
filename text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL,
storage_key text NOT NULL
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS reactions;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS reactions (
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
emoji text NOT NULL,
created_at timestamptz DEFAULT now(),
PRIMARY KEY (message_id, user_id, emoji)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS mentions;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS mentions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
target_type text NOT NULL CHECK (target_type IN ('user', 'channel', 'here')),
target_id uuid
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notifications;
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('mention', 'dm', 'thread_reply', 'invite', 'system')),
source_id uuid,
read boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS api_users;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS api_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id),
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS api_tokens;
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
api_user_id uuid NOT NULL REFERENCES api_users (id) ON DELETE CASCADE,
token_hash text NOT NULL,
scopes text[] DEFAULT '{}',
expires_at timestamptz,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS webhooks;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS webhooks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
name text NOT NULL,
avatar_url text,
token_hash text NOT NULL,
created_by uuid REFERENCES users (id),
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS invites;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id),
code text UNIQUE NOT NULL,
max_uses int,
uses int DEFAULT 0,
expires_at timestamptz,
created_at timestamptz DEFAULT now()
);
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_messages_channel_created;
DROP INDEX IF EXISTS idx_messages_parent;
DROP INDEX IF EXISTS idx_messages_search;
DROP INDEX IF EXISTS idx_notifications_user_unread;
DROP INDEX IF EXISTS idx_channel_members_user;
DROP INDEX IF EXISTS idx_community_members_user;
@@ -0,0 +1,20 @@
CREATE INDEX IF NOT EXISTS idx_messages_channel_created
ON messages (channel_id, created_at);
--;;
CREATE INDEX IF NOT EXISTS idx_messages_parent
ON messages (parent_id)
WHERE parent_id IS NOT NULL;
--;;
CREATE INDEX IF NOT EXISTS idx_messages_search
ON messages
USING GIN (to_tsvector('english', body_md));
--;;
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON notifications (user_id, created_at)
WHERE read = false;
--;;
CREATE INDEX IF NOT EXISTS idx_channel_members_user
ON channel_members (user_id);
--;;
CREATE INDEX IF NOT EXISTS idx_community_members_user
ON community_members (user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS bans;
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS bans (
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
reason text,
banned_by uuid REFERENCES users (id),
created_at timestamptz DEFAULT now(),
PRIMARY KEY (community_id, user_id)
);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_mutes_expires;
DROP TABLE IF EXISTS mutes;
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS mutes (
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at timestamptz,
muted_by uuid REFERENCES users (id),
created_at timestamptz DEFAULT now(),
PRIMARY KEY (community_id, user_id)
);
--;;
CREATE INDEX IF NOT EXISTS idx_mutes_expires
ON mutes (expires_at)
WHERE expires_at IS NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS oauth_providers;
@@ -0,0 +1,16 @@
CREATE TABLE oauth_providers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitea', 'oidc')),
display_name text NOT NULL,
slug text UNIQUE NOT NULL,
client_id text NOT NULL,
client_secret text NOT NULL,
base_url text,
issuer_url text,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
--;;
CREATE INDEX idx_oauth_providers_enabled ON oauth_providers (enabled, sort_order);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS system_settings;
@@ -0,0 +1,8 @@
CREATE TABLE system_settings (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz DEFAULT now()
);
--;;
INSERT INTO system_settings (key, value) VALUES ('setup_completed', 'false')
ON CONFLICT (key) DO NOTHING;
+124 -2
View File
@@ -1,5 +1,127 @@
(ns ajet.chat.api.core
"REST API service — http-kit + reitit.")
"REST API service — http-kit + reitit.
Manages the full lifecycle: DB pool, NATS, MinIO, HTTP server.
System state held in a single atom for REPL-driven development."
(:refer-clojure :exclude [reset!])
(:require [clojure.tools.logging :as log]
[org.httpkit.server :as http-kit]
[ajet.chat.shared.config :as config]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.shared.storage :as storage]
[ajet.chat.api.routes :as routes])
(:gen-class))
(defonce system (atom nil))
;;; ---------------------------------------------------------------------------
;;; Lifecycle
;;; ---------------------------------------------------------------------------
(defn start!
"Start the API service. Connects to PG, NATS, MinIO and starts HTTP server."
[& [{:keys [config-overrides]}]]
(when @system
(log/warn "System already started — call (stop!) first")
(throw (ex-info "System already running" {})))
(let [config (config/load-config {:resource "api-config.edn"})
config (if config-overrides
(merge config config-overrides)
config)
_ (log/info "Loaded config:" (config/redact config))
;; Database
ds (db/make-datasource (:db config))
_ (log/info "Database connection pool created")
_ (when (get-in config [:db :migrations :enabled] true)
(db/migrate! ds (get-in config [:db :migrations])))
;; NATS
nats (eventbus/connect! (:nats config))
_ (log/info "Connected to NATS")
_ (eventbus/ensure-stream! nats)
;; MinIO / S3
s3-client (storage/make-client (:minio config))
bucket (get-in config [:minio :bucket] "ajet-chat")
_ (storage/ensure-bucket! s3-client bucket)
_ (log/info "MinIO connected, bucket ensured:" bucket)
;; System map
sys {:config config
:ds ds
:nats nats
:s3 s3-client
:bucket bucket}
;; HTTP server
handler (routes/app sys)
port (get-in config [:server :port] 3001)
host (get-in config [:server :host] "0.0.0.0")
server (http-kit/run-server handler {:port port :ip host
:max-body (* 12 1024 1024)})]
(clojure.core/reset! system (assoc sys :server server :port port))
(log/info (str "API service started on port " port))
@system))
(defn stop!
"Stop the API service. Shuts down HTTP, NATS, DB pool in order."
[]
(when-let [sys @system]
(log/info "Shutting down API service...")
;; Stop HTTP server (wait up to 30s for in-flight requests)
(when-let [server (:server sys)]
(server :timeout 30000)
(log/info "HTTP server stopped"))
;; Close NATS
(when-let [nats (:nats sys)]
(try
(eventbus/close! nats)
(log/info "NATS connection closed")
(catch Exception e
(log/error e "Error closing NATS connection"))))
;; Close S3 client
(when-let [s3 (:s3 sys)]
(try
(storage/close-client s3)
(log/info "S3 client closed")
(catch Exception e
(log/error e "Error closing S3 client"))))
;; Close DB pool
(when-let [ds (:ds sys)]
(try
(db/close-datasource ds)
(log/info "Database connection pool closed")
(catch Exception e
(log/error e "Error closing database pool"))))
(clojure.core/reset! system nil)
(log/info "API service stopped")))
(defn reset!
"Stop then start the system (REPL convenience)."
[]
(stop!)
(start!))
;;; ---------------------------------------------------------------------------
;;; Entry point
;;; ---------------------------------------------------------------------------
(defn -main [& _args]
(println "ajet-chat API starting..."))
(start!)
;; Graceful shutdown hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. ^Runnable (fn []
(log/info "Shutdown hook triggered")
(stop!))))
;; Block main thread
@(promise))
+125
View File
@@ -0,0 +1,125 @@
(ns ajet.chat.api.handlers.admin
"Admin endpoints for managing OAuth providers.
Authorization: user must be owner of at least one community.
These endpoints allow post-setup management of OAuth providers
stored in the database."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-admin!
"Verify the user is an owner of at least one community.
Returns the user-id or throws 403."
[request]
(let [user-id (or (:user-id request)
(throw (ex-info "Authentication required"
{:type :ajet.chat/forbidden})))
ds (get-in request [:system :ds])
owner? (db/execute-one! ds
{:select [[:1 :exists]]
:from [:community-members]
:where [:and
[:= :user-id [:cast user-id :uuid]]
[:= :role "owner"]]})]
(when-not owner?
(throw (ex-info "Admin access required (must be community owner)"
{:type :ajet.chat/forbidden})))
user-id))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-providers
"GET /api/admin/oauth-providers — list all OAuth providers (including disabled)."
[request]
(require-admin! request)
(let [ds (get-in request [:system :ds])
providers (db/execute! ds
{:select [:id :provider-type :display-name :slug
:client-id :base-url :issuer-url
:enabled :sort-order :created-at :updated-at]
:from [:oauth-providers]
:order-by [[:sort-order :asc] [:created-at :asc]]})]
(mw/json-response providers)))
(defn create-provider
"POST /api/admin/oauth-providers — create a new OAuth provider."
[request]
(require-admin! request)
(let [ds (get-in request [:system :ds])
params (:body-params request)
{:keys [provider-type display-name slug client-id client-secret
base-url issuer-url enabled sort-order]} params]
(when (or (str/blank? provider-type) (str/blank? display-name)
(str/blank? slug) (str/blank? client-id) (str/blank? client-secret))
(throw (ex-info "provider-type, display-name, slug, client-id, and client-secret are required"
{:type :ajet.chat/validation-error})))
(when-not (#{"github" "gitea" "oidc"} provider-type)
(throw (ex-info "provider-type must be github, gitea, or oidc"
{:type :ajet.chat/validation-error})))
(let [provider (db/execute-one! ds
{:insert-into :oauth-providers
:values [(cond-> {:provider-type provider-type
:display-name display-name
:slug slug
:client-id client-id
:client-secret client-secret}
base-url (assoc :base-url base-url)
issuer-url (assoc :issuer-url issuer-url)
(some? enabled) (assoc :enabled enabled)
(some? sort-order) (assoc :sort-order sort-order))]
:returning [:id :provider-type :display-name :slug
:client-id :base-url :issuer-url
:enabled :sort-order :created-at :updated-at]})]
(log/info "Admin created OAuth provider" slug provider-type)
(mw/json-response 201 provider))))
(defn update-provider
"PUT /api/admin/oauth-providers/:id — update an existing OAuth provider."
[request]
(require-admin! request)
(let [ds (get-in request [:system :ds])
provider-id (get-in request [:path-params :id])
params (:body-params request)
updates (cond-> {}
(:display-name params) (assoc :display-name (:display-name params))
(:slug params) (assoc :slug (:slug params))
(:client-id params) (assoc :client-id (:client-id params))
(:client-secret params) (assoc :client-secret (:client-secret params))
(:base-url params) (assoc :base-url (:base-url params))
(:issuer-url params) (assoc :issuer-url (:issuer-url params))
(some? (:enabled params)) (assoc :enabled (:enabled params))
(some? (:sort-order params)) (assoc :sort-order (:sort-order params)))]
(when (empty? updates)
(throw (ex-info "No fields to update" {:type :ajet.chat/validation-error})))
(let [result (db/execute-one! ds
{:update :oauth-providers
:set (assoc updates :updated-at [:now])
:where [:= :id [:cast provider-id :uuid]]
:returning [:id :provider-type :display-name :slug
:client-id :base-url :issuer-url
:enabled :sort-order :created-at :updated-at]})]
(if result
(do (log/info "Admin updated OAuth provider" provider-id)
(mw/json-response result))
(mw/error-response 404 "NOT_FOUND" "OAuth provider not found")))))
(defn delete-provider
"DELETE /api/admin/oauth-providers/:id — delete an OAuth provider."
[request]
(require-admin! request)
(let [ds (get-in request [:system :ds])
provider-id (get-in request [:path-params :id])]
(db/execute! ds
{:delete-from :oauth-providers
:where [:= :id [:cast provider-id :uuid]]})
(log/info "Admin deleted OAuth provider" provider-id)
(mw/json-response 204 nil)))
@@ -0,0 +1,149 @@
(ns ajet.chat.api.handlers.categories
"Channel category CRUD handlers.
Categories organize channels within a community. Each has a name
and a position for ordering. Deleting a category sets channels
in it to uncategorized (category_id = NULL)."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-membership! [ds community-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (check-membership! ds community-id user-id)
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-categories
"GET /api/communities/:cid/categories
Returns categories ordered by position."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
_ (check-membership! ds community-id user-id)
categories (db/execute! ds
{:select [:*]
:from [:channel-categories]
:where [:= :community-id [:cast community-id :uuid]]
:order-by [[:position :asc]]})]
(mw/json-response categories)))
(defn create-category
"POST /api/communities/:cid/categories
Creates a new category. Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
params (:body-params request)
name-v (:name params)
position (or (:position params) 0)]
(check-role! ds community-id user-id "admin")
(when (str/blank? name-v)
(throw (ex-info "Category name is required"
{:type :ajet.chat/validation-error})))
(let [category-id (uuid)]
(db/execute! ds
{:insert-into :channel-categories
:values [{:id [:cast category-id :uuid]
:community-id [:cast community-id :uuid]
:name name-v
:position position}]})
(let [category (db/execute-one! ds
{:select [:*]
:from [:channel-categories]
:where [:= :id [:cast category-id :uuid]]})]
(mw/json-response 201 category)))))
(defn update-category
"PUT /api/categories/:id
Updates category name and/or position. Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
category-id (get-in request [:path-params :id])
params (:body-params request)
category (or (db/execute-one! ds
{:select [:*]
:from [:channel-categories]
:where [:= :id [:cast category-id :uuid]]})
(throw (ex-info "Category not found"
{:type :ajet.chat/not-found})))
community-id (str (:community-id category))]
(check-role! ds community-id user-id "admin")
(let [updates (cond-> {}
(:name params) (assoc :name (:name params))
(contains? params :position) (assoc :position (:position params)))]
(when (seq updates)
(db/execute! ds
{:update :channel-categories
:set updates
:where [:= :id [:cast category-id :uuid]]}))
(let [updated (db/execute-one! ds
{:select [:*]
:from [:channel-categories]
:where [:= :id [:cast category-id :uuid]]})]
(mw/json-response updated)))))
(defn delete-category
"DELETE /api/categories/:id
Deletes category. Channels become uncategorized (ON DELETE SET NULL). Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
category-id (get-in request [:path-params :id])
category (or (db/execute-one! ds
{:select [:*]
:from [:channel-categories]
:where [:= :id [:cast category-id :uuid]]})
(throw (ex-info "Category not found"
{:type :ajet.chat/not-found})))
community-id (str (:community-id category))]
(check-role! ds community-id user-id "admin")
;; ON DELETE SET NULL on channels.category_id handles the uncategorizing
(db/execute! ds
{:delete-from :channel-categories
:where [:= :id [:cast category-id :uuid]]})
(mw/json-response 204 nil)))
+331
View File
@@ -0,0 +1,331 @@
(ns ajet.chat.api.handlers.channels
"Channel CRUD, join/leave, and member listing handlers.
Channels belong to a community. DM channels have nil community_id
and are handled separately in the DMs handler."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-membership! [ds community-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (check-membership! ds community-id user-id)
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- check-channel-member! [ds channel-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this channel"
{:type :ajet.chat/forbidden}))))
(defn- get-channel-row [ds channel-id]
(or (db/execute-one! ds
{:select [:*]
:from [:channels]
:where [:= :id [:cast channel-id :uuid]]})
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-channels
"GET /api/communities/:cid/channels
Returns public channels + private channels user is a member of."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
_ (check-membership! ds community-id user-id)
channels (db/execute! ds
{:select-distinct [:c.*]
:from [[:channels :c]]
:left-join [[:channel-members :cm]
[:and
[:= :cm.channel-id :c.id]
[:= :cm.user-id [:cast user-id :uuid]]]]
:where [:and
[:= :c.community-id [:cast community-id :uuid]]
[:or
[:= :c.visibility "public"]
[:!= :cm.user-id nil]]]
:order-by [[:c.name :asc]]})]
(mw/json-response channels)))
(defn create-channel
"POST /api/communities/:cid/channels
Creates a channel. Admin+ only. Adds creator as member."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :cid])
params (:body-params request)
name-v (:name params)
ch-type (or (:type params) "text")
visibility (or (:visibility params) "public")
topic (:topic params)
category-id (:category_id params)]
(check-role! ds community-id user-id "admin")
(when (str/blank? name-v)
(throw (ex-info "Channel name is required"
{:type :ajet.chat/validation-error})))
;; Check for duplicate name in community
(when (db/execute-one! ds
{:select [:id]
:from [:channels]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :name name-v]]})
(throw (ex-info "A channel with this name already exists in this community"
{:type :ajet.chat/conflict})))
(let [channel-id (uuid)]
(db/with-transaction [tx ds]
(db/execute! tx
{:insert-into :channels
:values [(cond-> {:id [:cast channel-id :uuid]
:community-id [:cast community-id :uuid]
:name name-v
:type ch-type
:visibility visibility}
topic (assoc :topic topic)
category-id (assoc :category-id [:cast category-id :uuid]))]})
;; Add creator as channel member
(db/execute! tx
{:insert-into :channel-members
:values [{:channel-id [:cast channel-id :uuid]
:user-id [:cast user-id :uuid]}]}))
(let [channel (get-channel-row ds channel-id)]
(publish-event! nats
(str "chat.events." community-id)
:channel/created
{:channel-id channel-id
:community-id community-id
:name name-v
:type ch-type
:visibility visibility
:created-by user-id})
(mw/json-response 201 channel)))))
(defn get-channel
"GET /api/channels/:id
Returns channel details. Accessible to channel members, or community members for public channels."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
channel-id (get-in request [:path-params :id])
channel (get-channel-row ds channel-id)]
;; Access check: channel member OR (public channel + community member)
(let [is-channel-member (db/execute-one! ds
{:select [:user-id]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})]
(when-not is-channel-member
(if (and (= "public" (:visibility channel))
(:community-id channel))
(check-membership! ds (str (:community-id channel)) user-id)
(throw (ex-info "Not a member of this channel"
{:type :ajet.chat/forbidden})))))
(mw/json-response channel)))
(defn update-channel
"PUT /api/channels/:id
Updates channel name, topic, category. Admin+ of the community."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
channel-id (get-in request [:path-params :id])
params (:body-params request)
channel (get-channel-row ds channel-id)
community-id (str (:community-id channel))]
(check-role! ds community-id user-id "admin")
(let [updates (cond-> {}
(:name params) (assoc :name (:name params))
(:topic params) (assoc :topic (:topic params))
(contains? params :category_id)
(assoc :category-id (when (:category_id params)
[:cast (:category_id params) :uuid]))
(:visibility params) (assoc :visibility (:visibility params)))]
(when (seq updates)
(db/execute! ds
{:update :channels
:set updates
:where [:= :id [:cast channel-id :uuid]]}))
(let [updated (get-channel-row ds channel-id)]
(publish-event! nats
(str "chat.events." community-id)
:channel/updated
{:channel-id channel-id
:community-id community-id
:updated-by user-id
:changes (dissoc updates :category-id)})
(mw/json-response updated)))))
(defn delete-channel
"DELETE /api/channels/:id
Deletes channel and all messages. Admin+ of the community."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
channel-id (get-in request [:path-params :id])
channel (get-channel-row ds channel-id)
community-id (str (:community-id channel))]
(check-role! ds community-id user-id "admin")
;; CASCADE handles cleanup of messages, members, etc.
(db/execute! ds
{:delete-from :channels
:where [:= :id [:cast channel-id :uuid]]})
(publish-event! nats
(str "chat.events." community-id)
:channel/deleted
{:channel-id channel-id
:community-id community-id
:name (:name channel)
:deleted-by user-id})
(mw/json-response 204 nil)))
(defn join-channel
"POST /api/channels/:id/join
Joins a public channel. Community membership required."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
channel-id (get-in request [:path-params :id])
channel (get-channel-row ds channel-id)
community-id (str (:community-id channel))]
(check-membership! ds community-id user-id)
(when (= "private" (:visibility channel))
(throw (ex-info "Cannot join a private channel without an invite"
{:type :ajet.chat/forbidden})))
;; Idempotent: ignore if already a member
(let [existing (db/execute-one! ds
{:select [:user-id]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})]
(when-not existing
(db/execute! ds
{:insert-into :channel-members
:values [{:channel-id [:cast channel-id :uuid]
:user-id [:cast user-id :uuid]}]})
(publish-event! nats
(str "chat.events." community-id)
:member/joined
{:channel-id channel-id
:community-id community-id
:user-id user-id})))
(mw/json-response {:status "joined" :channel-id channel-id})))
(defn leave-channel
"POST /api/channels/:id/leave
Leaves a channel."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
channel-id (get-in request [:path-params :id])
channel (get-channel-row ds channel-id)
community-id (when (:community-id channel)
(str (:community-id channel)))]
(db/execute! ds
{:delete-from :channel-members
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(when community-id
(publish-event! nats
(str "chat.events." community-id)
:member/left
{:channel-id channel-id
:community-id community-id
:user-id user-id}))
(mw/json-response {:status "left" :channel-id channel-id})))
(defn list-channel-members
"GET /api/channels/:id/members
Lists members of a channel. Requires channel membership."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
channel-id (get-in request [:path-params :id])]
(check-channel-member! ds channel-id user-id)
(let [members (db/execute! ds
{:select [:u.id :u.username :u.display-name :u.avatar-url
:u.status-text :cm.joined-at]
:from [[:channel-members :cm]]
:join [[:users :u] [:= :u.id :cm.user-id]]
:where [:= :cm.channel-id [:cast channel-id :uuid]]
:order-by [[:u.username :asc]]})]
(mw/json-response members))))
+451
View File
@@ -0,0 +1,451 @@
(ns ajet.chat.api.handlers.commands
"Slash command dispatcher.
Parses command strings and dispatches to the appropriate handler.
Commands: /help, /topic, /nick, /invite, /kick, /ban, /mute,
/token, /webhook, /status"
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]
[java.security SecureRandom MessageDigest]
[java.util Base64]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden})))
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- parse-user-mention
"Extract user UUID from @<user:uuid> syntax."
[s]
(when-let [[_ uid] (re-matches #"@<user:([0-9a-f-]+)>" s)]
uid))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
(defn- generate-token []
(let [bytes (byte-array 32)
sr (SecureRandom.)]
(.nextBytes sr bytes)
(.encodeToString (Base64/getUrlEncoder) bytes)))
(defn- hash-token [token]
(let [md (MessageDigest/getInstance "SHA-256")
bytes (.digest md (.getBytes token "UTF-8"))]
(.encodeToString (Base64/getUrlEncoder) bytes)))
(defn- generate-invite-code []
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sr (SecureRandom.)
sb (StringBuilder. 8)]
(dotimes [_ 8]
(.append sb (.charAt chars (.nextInt sr (count chars)))))
(.toString sb)))
;;; ---------------------------------------------------------------------------
;;; Command Handlers
;;; ---------------------------------------------------------------------------
(def ^:private help-text
{"help" {:description "Show available commands" :usage "/help [command]" :permission "All"}
"topic" {:description "Set channel topic" :usage "/topic <text>" :permission "Admin+"}
"nick" {:description "Set community nickname" :usage "/nick <nickname>" :permission "All"}
"invite" {:description "Generate invite link" :usage "/invite [max_uses] [expires_hours]" :permission "Admin+"}
"kick" {:description "Kick user from community" :usage "/kick @<user>" :permission "Admin+"}
"ban" {:description "Ban user from community" :usage "/ban @<user>" :permission "Admin+"}
"mute" {:description "Mute user for duration" :usage "/mute @<user> <duration>" :permission "Admin+"}
"token" {:description "Manage API tokens" :usage "/token create|revoke|list" :permission "Owner"}
"webhook" {:description "Manage webhooks" :usage "/webhook create|delete|list" :permission "Admin+"}
"status" {:description "Set status text" :usage "/status <text>" :permission "All"}})
(defn- cmd-help [_ds _nats _user-id _community-id _channel-id args]
(if (and (seq args) (get help-text (first args)))
(let [cmd (get help-text (first args))]
{:result (str "**/" (first args) "** - " (:description cmd)
"\nUsage: `" (:usage cmd) "`"
"\nPermission: " (:permission cmd))})
{:result (str "**Available commands:**\n"
(str/join "\n"
(map (fn [[name {:keys [description]}]]
(str " `/" name "` - " description))
(sort-by key help-text))))}))
(defn- cmd-topic [ds nats user-id community-id channel-id args]
(check-role! ds community-id user-id "admin")
(when (empty? args)
(throw (ex-info "Usage: /topic <text>" {:type :ajet.chat/validation-error})))
(let [topic (str/join " " args)]
(db/execute! ds
{:update :channels
:set {:topic topic}
:where [:= :id [:cast channel-id :uuid]]})
(publish-event! nats
(str "chat.events." community-id)
:channel/updated
{:channel-id channel-id :topic topic :updated-by user-id})
{:result (str "Channel topic set to: " topic)}))
(defn- cmd-nick [ds _nats user-id community-id _channel-id args]
(when (empty? args)
(throw (ex-info "Usage: /nick <nickname>" {:type :ajet.chat/validation-error})))
(let [nickname (str/join " " args)]
(db/execute! ds
{:update :community-members
:set {:nickname nickname}
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
{:result (str "Nickname set to: " nickname)}))
(defn- cmd-invite [ds _nats user-id community-id _channel-id args]
(check-role! ds community-id user-id "admin")
(let [max-uses (some-> (first args) parse-long)
expires-hrs (some-> (second args) parse-long)
invite-id (uuid)
code (generate-invite-code)
values (cond-> {:id [:cast invite-id :uuid]
:community-id [:cast community-id :uuid]
:created-by [:cast user-id :uuid]
:code code}
max-uses (assoc :max-uses max-uses)
expires-hrs (assoc :expires-at
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
(db/execute! ds
{:insert-into :invites
:values [values]})
{:result (str "Invite created: `/invite/" code "`"
(when max-uses (str " (max " max-uses " uses)"))
(when expires-hrs (str " (expires in " expires-hrs "h)")))}))
(defn- cmd-kick [ds nats user-id community-id _channel-id args]
(check-role! ds community-id user-id "admin")
(when (empty? args)
(throw (ex-info "Usage: /kick @<user>" {:type :ajet.chat/validation-error})))
(let [target-uid (or (parse-user-mention (first args))
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
{:type :ajet.chat/validation-error})))
target (or (db/execute-one! ds
{:select [:role]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})
(throw (ex-info "User is not a member of this community"
{:type :ajet.chat/not-found})))]
(when (= (:role target) "owner")
(throw (ex-info "Cannot kick the community owner"
{:type :ajet.chat/forbidden})))
;; Remove from channels
(db/execute-sql! ds
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
target-uid community-id])
;; Remove community membership
(db/execute! ds
{:delete-from :community-members
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})
(publish-event! nats
(str "chat.events." community-id)
:member/kicked
{:community-id community-id :user-id target-uid :kicked-by user-id})
{:result (str "User " target-uid " has been kicked from the community.")}))
(defn- cmd-ban [ds nats user-id community-id _channel-id args]
(check-role! ds community-id user-id "admin")
(when (empty? args)
(throw (ex-info "Usage: /ban @<user>" {:type :ajet.chat/validation-error})))
(let [target-uid (or (parse-user-mention (first args))
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
{:type :ajet.chat/validation-error})))
target (db/execute-one! ds
{:select [:role]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})]
(when (and target (= (:role target) "owner"))
(throw (ex-info "Cannot ban the community owner"
{:type :ajet.chat/forbidden})))
(let [reason (when (> (count args) 1) (str/join " " (rest args)))]
;; Create ban record
(db/execute! ds
{:insert-into :bans
:values [(cond-> {:community-id [:cast community-id :uuid]
:user-id [:cast target-uid :uuid]
:banned-by [:cast user-id :uuid]}
reason (assoc :reason reason))]
:on-conflict [:community-id :user-id]
:do-nothing true})
;; Remove from channels
(db/execute-sql! ds
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
target-uid community-id])
;; Remove community membership
(db/execute! ds
{:delete-from :community-members
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})
(publish-event! nats
(str "chat.events." community-id)
:member/banned
{:community-id community-id :user-id target-uid :banned-by user-id :reason reason})
{:result (str "User " target-uid " has been banned from the community.")})))
(defn- parse-duration
"Parse duration string like '10m', '1h', '24h', '7d' to SQL interval."
[s]
(when-let [[_ n unit] (re-matches #"(\d+)(m|h|d)" s)]
(let [unit-name (case unit "m" "minutes" "h" "hours" "d" "days")]
(str n " " unit-name))))
(defn- cmd-mute [ds nats user-id community-id _channel-id args]
(check-role! ds community-id user-id "admin")
(when (< (count args) 2)
(throw (ex-info "Usage: /mute @<user> <duration> (e.g. 10m, 1h, 24h, 7d)"
{:type :ajet.chat/validation-error})))
(let [target-uid (or (parse-user-mention (first args))
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
{:type :ajet.chat/validation-error})))
duration-str (second args)
interval (or (parse-duration duration-str)
(throw (ex-info "Invalid duration. Use format like: 10m, 1h, 24h, 7d"
{:type :ajet.chat/validation-error})))]
;; Check target is not owner
(let [target (db/execute-one! ds
{:select [:role]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})]
(when (and target (= (:role target) "owner"))
(throw (ex-info "Cannot mute the community owner"
{:type :ajet.chat/forbidden}))))
;; Upsert mute record
(db/execute-sql! ds
[(str "INSERT INTO mutes (community_id, user_id, expires_at, muted_by) "
"VALUES (?::uuid, ?::uuid, now() + interval '" interval "', ?::uuid) "
"ON CONFLICT (community_id, user_id) "
"DO UPDATE SET expires_at = now() + interval '" interval "', muted_by = ?::uuid")
community-id target-uid user-id user-id])
{:result (str "User " target-uid " has been muted for " duration-str ".")}))
(defn- cmd-token [ds _nats user-id community-id _channel-id args]
(check-role! ds community-id user-id "owner")
(let [sub-cmd (first args)]
(case sub-cmd
"create"
(let [name-v (or (second args)
(throw (ex-info "Usage: /token create <name>"
{:type :ajet.chat/validation-error})))
api-user-id (uuid)
token-id (uuid)
token (generate-token)
token-h (hash-token token)]
(db/with-transaction [tx ds]
(db/execute! tx
{:insert-into :api-users
:values [{:id [:cast api-user-id :uuid]
:name name-v
:community-id [:cast community-id :uuid]
:created-by [:cast user-id :uuid]}]})
(db/execute! tx
{:insert-into :api-tokens
:values [{:id [:cast token-id :uuid]
:api-user-id [:cast api-user-id :uuid]
:token-hash token-h}]}))
{:result (str "API token created for **" name-v "**\n"
"Token: `" token "`\n"
"This token will only be shown once. Save it securely.")})
"revoke"
(let [name-v (or (second args)
(throw (ex-info "Usage: /token revoke <name>"
{:type :ajet.chat/validation-error})))
api-user (db/execute-one! ds
{:select [:id]
:from [:api-users]
:where [:and
[:= :name name-v]
[:= :community-id [:cast community-id :uuid]]]})]
(when-not api-user
(throw (ex-info (str "API user not found: " name-v)
{:type :ajet.chat/not-found})))
;; Delete the api_user (CASCADE deletes tokens)
(db/execute! ds
{:delete-from :api-users
:where [:= :id (:id api-user)]})
{:result (str "API token for **" name-v "** has been revoked.")})
"list"
(let [api-users (db/execute! ds
{:select [:au.id :au.name :au.created-at]
:from [[:api-users :au]]
:where [:= :au.community-id [:cast community-id :uuid]]
:order-by [[:au.created-at :desc]]})]
(if (empty? api-users)
{:result "No API tokens configured."}
{:result (str "**API Tokens:**\n"
(str/join "\n" (map #(str " - " (:name %) " (created " (:created-at %) ")")
api-users)))}))
;; default
(throw (ex-info "Usage: /token create|revoke|list <name>"
{:type :ajet.chat/validation-error})))))
(defn- cmd-webhook [ds _nats user-id community-id channel-id args]
(check-role! ds community-id user-id "admin")
(let [sub-cmd (first args)]
(case sub-cmd
"create"
(let [name-v (or (second args)
(throw (ex-info "Usage: /webhook create <name> [channel-id]"
{:type :ajet.chat/validation-error})))
target-ch (or (nth args 2 nil) channel-id)
webhook-id (uuid)
token (generate-token)
token-h (hash-token token)]
(db/execute! ds
{:insert-into :webhooks
:values [{:id [:cast webhook-id :uuid]
:community-id [:cast community-id :uuid]
:channel-id [:cast target-ch :uuid]
:name name-v
:token-hash token-h
:created-by [:cast user-id :uuid]}]})
{:result (str "Webhook **" name-v "** created.\n"
"URL: `/api/webhooks/" webhook-id "/incoming`\n"
"Token: `" token "`\n"
"This token will only be shown once.")})
"delete"
(let [name-v (or (second args)
(throw (ex-info "Usage: /webhook delete <name>"
{:type :ajet.chat/validation-error})))
webhook (db/execute-one! ds
{:select [:id]
:from [:webhooks]
:where [:and
[:= :name name-v]
[:= :community-id [:cast community-id :uuid]]]})]
(when-not webhook
(throw (ex-info (str "Webhook not found: " name-v)
{:type :ajet.chat/not-found})))
(db/execute! ds
{:delete-from :webhooks
:where [:= :id (:id webhook)]})
{:result (str "Webhook **" name-v "** deleted.")})
"list"
(let [webhooks (db/execute! ds
{:select [:name :channel-id :created-at]
:from [:webhooks]
:where [:= :community-id [:cast community-id :uuid]]
:order-by [[:created-at :desc]]})]
(if (empty? webhooks)
{:result "No webhooks configured."}
{:result (str "**Webhooks:**\n"
(str/join "\n" (map #(str " - " (:name %)
" -> channel " (:channel-id %))
webhooks)))}))
;; default
(throw (ex-info "Usage: /webhook create|delete|list <name>"
{:type :ajet.chat/validation-error})))))
(defn- cmd-status [ds _nats user-id _community-id _channel-id args]
(let [status-text (str/join " " args)]
(db/execute! ds
{:update :users
:set {:status-text (when (seq status-text) status-text)}
:where [:= :id [:cast user-id :uuid]]})
{:result (if (seq status-text)
(str "Status set to: " status-text)
"Status cleared.")}))
;;; ---------------------------------------------------------------------------
;;; Dispatcher
;;; ---------------------------------------------------------------------------
(def ^:private command-handlers
{"help" cmd-help
"topic" cmd-topic
"nick" cmd-nick
"invite" cmd-invite
"kick" cmd-kick
"ban" cmd-ban
"mute" cmd-mute
"token" cmd-token
"webhook" cmd-webhook
"status" cmd-status})
(defn execute-command
"POST /api/commands
Parses and dispatches a slash command.
Body: {\"command\": \"/kick @<user:uuid>\", \"channel_id\": \"uuid\", \"community_id\": \"uuid\"}"
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
params (:body-params request)
command-str (:command params)
channel-id (:channel_id params)
community-id (:community_id params)]
(when (str/blank? command-str)
(throw (ex-info "command is required"
{:type :ajet.chat/validation-error})))
(when (str/blank? community-id)
(throw (ex-info "community_id is required"
{:type :ajet.chat/validation-error})))
;; Parse command: "/cmd arg1 arg2 ..."
(let [parts (str/split (str/trim command-str) #"\s+")
cmd-name (when (str/starts-with? (first parts) "/")
(subs (first parts) 1))
args (rest parts)]
(when-not cmd-name
(throw (ex-info "Command must start with /"
{:type :ajet.chat/validation-error})))
(if-let [handler-fn (get command-handlers cmd-name)]
(let [result (handler-fn ds nats user-id community-id channel-id args)]
(mw/json-response result))
;; Unknown command
(mw/json-response {:result (str "Unknown command: /" cmd-name ". Type /help for available commands.")})))))
@@ -0,0 +1,242 @@
(ns ajet.chat.api.handlers.communities
"Community CRUD handlers.
Communities are the top-level organizational unit. Creating a community
makes the creator the owner and bootstraps a #general channel."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid []
(str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required"
{:type :ajet.chat/forbidden}))))
(defn- check-membership!
"Verify user is a member of the community. Returns the membership row."
[ds community-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
(defn- check-role!
"Verify user has the required role or higher. Returns membership row."
[ds community-id user-id required-role]
(let [member (check-membership! ds community-id user-id)
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn create-community
"POST /api/communities
Creates community, adds user as owner, creates #general channel."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
params (:body-params request)
name-v (:name params)
slug (:slug params)]
;; Validate required fields
(when (or (str/blank? name-v) (str/blank? slug))
(throw (ex-info "Name and slug are required"
{:type :ajet.chat/validation-error})))
;; Validate slug format
(when-not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" slug)
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
{:type :ajet.chat/validation-error})))
;; Check slug uniqueness
(when (db/execute-one! ds
{:select [:id]
:from [:communities]
:where [:= :slug slug]})
(throw (ex-info "A community with this slug already exists"
{:type :ajet.chat/conflict})))
(let [community-id (uuid)
channel-id (uuid)]
(db/with-transaction [tx ds]
;; Create community
(db/execute! tx
{:insert-into :communities
:values [{:id [:cast community-id :uuid]
:name name-v
:slug slug}]})
;; Add creator as owner
(db/execute! tx
{:insert-into :community-members
:values [{:community-id [:cast community-id :uuid]
:user-id [:cast user-id :uuid]
:role "owner"}]})
;; Create #general channel
(db/execute! tx
{:insert-into :channels
:values [{:id [:cast channel-id :uuid]
:community-id [:cast community-id :uuid]
:name "general"
:type "text"
:visibility "public"}]})
;; Add creator to #general
(db/execute! tx
{:insert-into :channel-members
:values [{:channel-id [:cast channel-id :uuid]
:user-id [:cast user-id :uuid]}]}))
;; Fetch the created community
(let [community (db/execute-one! ds
{:select [:*]
:from [:communities]
:where [:= :id [:cast community-id :uuid]]})]
;; Publish event
(publish-event! nats
(str "chat.events." community-id)
:community/created
{:community-id community-id
:name name-v
:slug slug
:created-by user-id})
(mw/json-response 201 community)))))
(defn list-communities
"GET /api/communities
Returns communities the authenticated user belongs to."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
communities (db/execute! ds
{:select [:c.*]
:from [[:communities :c]]
:join [[:community-members :cm]
[:= :c.id :cm.community-id]]
:where [:= :cm.user-id [:cast user-id :uuid]]
:order-by [[:c.name :asc]]})]
(mw/json-response communities)))
(defn get-community
"GET /api/communities/:id
Returns community details. Requires membership."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :id])
_ (check-membership! ds community-id user-id)
community (db/execute-one! ds
{:select [:*]
:from [:communities]
:where [:= :id [:cast community-id :uuid]]})]
(if community
(mw/json-response community)
(mw/error-response 404 "NOT_FOUND" "Community not found"))))
(defn update-community
"PUT /api/communities/:id
Updates community name/slug. Owner only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :id])
params (:body-params request)]
(check-role! ds community-id user-id "owner")
;; Validate slug format if provided
(when (and (:slug params) (not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" (:slug params))))
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
{:type :ajet.chat/validation-error})))
;; Check slug uniqueness if changing
(when (:slug params)
(let [existing (db/execute-one! ds
{:select [:id]
:from [:communities]
:where [:and
[:= :slug (:slug params)]
[:!= :id [:cast community-id :uuid]]]})]
(when existing
(throw (ex-info "A community with this slug already exists"
{:type :ajet.chat/conflict})))))
(let [updates (cond-> {}
(:name params) (assoc :name (:name params))
(:slug params) (assoc :slug (:slug params)))]
(when (seq updates)
(db/execute! ds
{:update :communities
:set updates
:where [:= :id [:cast community-id :uuid]]}))
(let [community (db/execute-one! ds
{:select [:*]
:from [:communities]
:where [:= :id [:cast community-id :uuid]]})]
(publish-event! nats
(str "chat.events." community-id)
:community/updated
{:community-id community-id
:updated-by user-id
:changes updates})
(mw/json-response community)))))
(defn delete-community
"DELETE /api/communities/:id
Deletes community and all associated data. Owner only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :id])]
(check-role! ds community-id user-id "owner")
;; CASCADE handles cleanup of members, channels, messages, etc.
(db/execute! ds
{:delete-from :communities
:where [:= :id [:cast community-id :uuid]]})
(publish-event! nats
(str "chat.events." community-id)
:community/deleted
{:community-id community-id
:deleted-by user-id})
(mw/json-response 204 nil)))
+169
View File
@@ -0,0 +1,169 @@
(ns ajet.chat.api.handlers.dms
"DM (Direct Message) handlers.
DMs are channels with type 'dm' or 'group_dm' and a NULL community_id.
Creating a 1:1 DM is idempotent - returns existing if one exists
between the two users."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- channel-with-members
"Fetch a DM channel with its members."
[ds channel-id]
(let [channel (db/execute-one! ds
{:select [:*]
:from [:channels]
:where [:= :id [:cast channel-id :uuid]]})
members (db/execute! ds
{:select [:u.id :u.username :u.display-name :u.avatar-url]
:from [[:channel-members :cm]]
:join [[:users :u] [:= :u.id :cm.user-id]]
:where [:= :cm.channel-id [:cast channel-id :uuid]]})]
(assoc channel :members members)))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-dms
"GET /api/dms
Lists all DM/group_dm channels the user is a member of.
Ordered by most recent message activity."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
;; Get DM channels the user is in, ordered by latest message
channels (db/execute! ds
{:select [:c.*]
:from [[:channels :c]]
:join [[:channel-members :cm]
[:= :cm.channel-id :c.id]]
:where [:and
[:= :cm.user-id [:cast user-id :uuid]]
[:in :c.type ["dm" "group_dm"]]]
:order-by [[:c.created-at :desc]]})
;; Enrich with member info
result (mapv (fn [ch]
(let [members (db/execute! ds
{:select [:u.id :u.username :u.display-name :u.avatar-url]
:from [[:channel-members :chm]]
:join [[:users :u] [:= :u.id :chm.user-id]]
:where [:= :chm.channel-id (:id ch)]})]
(assoc ch :members members)))
channels)]
(mw/json-response result)))
(defn create-dm
"POST /api/dms
Creates a 1:1 DM channel, or returns existing one if it already exists."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:body-params request)
target-id (:user_id params)]
(when (str/blank? target-id)
(throw (ex-info "user_id is required"
{:type :ajet.chat/validation-error})))
(when (= user-id target-id)
(throw (ex-info "Cannot create a DM with yourself"
{:type :ajet.chat/validation-error})))
;; Check target user exists
(when-not (db/execute-one! ds
{:select [:id]
:from [:users]
:where [:= :id [:cast target-id :uuid]]})
(throw (ex-info "Target user not found"
{:type :ajet.chat/not-found})))
;; Check for existing DM between these two users
(let [existing (db/execute-one! ds
{:select [:c.id]
:from [[:channels :c]]
:join [[:channel-members :cm1]
[:= :cm1.channel-id :c.id]
[:channel-members :cm2]
[:= :cm2.channel-id :c.id]]
:where [:and
[:= :c.type "dm"]
[:= :cm1.user-id [:cast user-id :uuid]]
[:= :cm2.user-id [:cast target-id :uuid]]]})]
(if existing
;; Return existing DM
(mw/json-response (channel-with-members ds (str (:id existing))))
;; Create new DM
(let [channel-id (uuid)]
(db/with-transaction [tx ds]
(db/execute! tx
{:insert-into :channels
:values [{:id [:cast channel-id :uuid]
:name "dm"
:type "dm"
:visibility "private"}]})
(db/execute! tx
{:insert-into :channel-members
:values [{:channel-id [:cast channel-id :uuid]
:user-id [:cast user-id :uuid]}
{:channel-id [:cast channel-id :uuid]
:user-id [:cast target-id :uuid]}]}))
(mw/json-response 201 (channel-with-members ds channel-id)))))))
(defn create-group-dm
"POST /api/dms/group
Creates a group DM with multiple users."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:body-params request)
user-ids (:user_ids params)]
(when (or (nil? user-ids) (< (count user-ids) 2))
(throw (ex-info "At least 2 other user IDs are required for a group DM"
{:type :ajet.chat/validation-error})))
;; Ensure all members include the creator
(let [all-member-ids (distinct (cons user-id user-ids))
channel-id (uuid)]
;; Verify all users exist
(doseq [uid user-ids]
(when-not (db/execute-one! ds
{:select [:id]
:from [:users]
:where [:= :id [:cast uid :uuid]]})
(throw (ex-info (str "User not found: " uid)
{:type :ajet.chat/not-found}))))
(db/with-transaction [tx ds]
(db/execute! tx
{:insert-into :channels
:values [{:id [:cast channel-id :uuid]
:name "group-dm"
:type "group_dm"
:visibility "private"}]})
(db/execute! tx
{:insert-into :channel-members
:values (mapv (fn [uid]
{:channel-id [:cast channel-id :uuid]
:user-id [:cast uid :uuid]})
all-member-ids)}))
(mw/json-response 201 (channel-with-members ds channel-id)))))
+73
View File
@@ -0,0 +1,73 @@
(ns ajet.chat.api.handlers.health
"Health check handler.
Checks connectivity to DB, NATS, and MinIO. Returns 200 if all healthy,
503 if any check fails (degraded mode)."
(:require [clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [software.amazon.awssdk.services.s3 S3Client]
[software.amazon.awssdk.services.s3.model HeadBucketRequest]))
;;; ---------------------------------------------------------------------------
;;; Health Checks
;;; ---------------------------------------------------------------------------
(defn- check-db
"Test database connectivity with a simple query."
[ds]
(try
(db/execute-sql! ds ["SELECT 1"])
"ok"
(catch Exception e
(log/error e "DB health check failed")
"error")))
(defn- check-nats
"Test NATS connection status."
[nats]
(try
(if (and nats (eventbus/connected? nats))
"ok"
"error")
(catch Exception e
(log/error e "NATS health check failed")
"error")))
(defn- check-minio
"Test MinIO/S3 connectivity by checking if the bucket exists."
[^S3Client s3 bucket]
(try
(.headBucket s3 (-> (HeadBucketRequest/builder)
(.bucket bucket)
(.build)))
"ok"
(catch Exception e
(log/error e "MinIO health check failed")
"error")))
;;; ---------------------------------------------------------------------------
;;; Handler
;;; ---------------------------------------------------------------------------
(defn health-check
"GET /api/health
Returns health status for all dependencies.
200 = all ok, 503 = at least one check failed."
[request]
(let [ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
s3 (get-in request [:system :s3])
bucket (get-in request [:system :bucket])
db-status (check-db ds)
nats-status (check-nats nats)
minio-status (check-minio s3 bucket)
checks {:db db-status :nats nats-status :minio minio-status}
all-ok (every? #(= "ok" %) (vals checks))
status (if all-ok "ok" "degraded")
code (if all-ok 200 503)]
(mw/json-response code {:status status :checks checks})))
+297
View File
@@ -0,0 +1,297 @@
(ns ajet.chat.api.handlers.invites
"Invite management handlers.
Invites are links with unique codes that allow users to join a community.
They can have optional max_uses and expiry. Direct invites create a
notification for the target user."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]
[java.security SecureRandom]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-membership! [ds community-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (check-membership! ds community-id user-id)
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- generate-invite-code
"Generate a random alphanumeric invite code."
[]
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sr (SecureRandom.)
sb (StringBuilder. 8)]
(dotimes [_ 8]
(.append sb (.charAt chars (.nextInt sr (count chars)))))
(.toString sb)))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn create-invite
"POST /api/communities/:cid/invites
Creates an invite link. Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
params (:body-params request)
max-uses (:max_uses params)
expires-hrs (:expires_in_hours params)]
(check-role! ds community-id user-id "admin")
(let [invite-id (uuid)
code (generate-invite-code)
values (cond-> {:id [:cast invite-id :uuid]
:community-id [:cast community-id :uuid]
:created-by [:cast user-id :uuid]
:code code}
max-uses (assoc :max-uses max-uses)
expires-hrs (assoc :expires-at
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
(db/execute! ds
{:insert-into :invites
:values [values]})
(let [invite (db/execute-one! ds
{:select [:*]
:from [:invites]
:where [:= :id [:cast invite-id :uuid]]})]
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
(defn list-invites
"GET /api/communities/:cid/invites
Lists active (non-expired, non-exhausted) invites. Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])]
(check-role! ds community-id user-id "admin")
(let [invites (db/execute! ds
{:select [:*]
:from [:invites]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:or
[:= :expires-at nil]
[:> :expires-at [:now]]]
[:or
[:= :max-uses nil]
[:< :uses :max-uses]]]
:order-by [[:created-at :desc]]})]
(mw/json-response invites))))
(defn revoke-invite
"DELETE /api/invites/:id
Deletes an invite. Admin+ of the invite's community."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
invite-id (get-in request [:path-params :id])
invite (or (db/execute-one! ds
{:select [:*]
:from [:invites]
:where [:= :id [:cast invite-id :uuid]]})
(throw (ex-info "Invite not found"
{:type :ajet.chat/not-found})))]
(check-role! ds (str (:community-id invite)) user-id "admin")
(db/execute! ds
{:delete-from :invites
:where [:= :id [:cast invite-id :uuid]]})
(mw/json-response 204 nil)))
(defn accept-invite
"POST /api/invites/:code/accept
Accepts an invite and joins the community. Idempotent if already a member."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
code (get-in request [:path-params :code])
invite (or (db/execute-one! ds
{:select [:*]
:from [:invites]
:where [:= :code code]})
(throw (ex-info "Invite not found"
{:type :ajet.chat/not-found})))
community-id (str (:community-id invite))]
;; Check expiry
(when (:expires-at invite)
(let [expires-at (:expires-at invite)
now (java.time.Instant/now)
exp-inst (if (instance? java.time.Instant expires-at)
expires-at
(.toInstant expires-at))]
(when (.isAfter now exp-inst)
(throw (ex-info "This invite has expired"
{:type :ajet.chat/gone})))))
;; Check max uses
(when (and (:max-uses invite) (>= (:uses invite) (:max-uses invite)))
(throw (ex-info "This invite has reached its maximum number of uses"
{:type :ajet.chat/gone})))
;; Check if user is banned
(when (db/execute-one! ds
{:select [[:1 :exists]]
:from [:bans]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "You are banned from this community"
{:type :ajet.chat/forbidden})))
;; Check if already a member (idempotent)
(let [already-member (db/execute-one! ds
{:select [:user-id]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})]
(if already-member
(mw/json-response {:status "already_member" :community-id community-id})
(do
(db/with-transaction [tx ds]
;; Add as member
(db/execute! tx
{:insert-into :community-members
:values [{:community-id [:cast community-id :uuid]
:user-id [:cast user-id :uuid]
:role "member"}]})
;; Join #general channel
(when-let [general (db/execute-one! tx
{:select [:id]
:from [:channels]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :name "general"]]})]
(db/execute! tx
{:insert-into :channel-members
:values [{:channel-id (:id general)
:user-id [:cast user-id :uuid]}]
:on-conflict [:channel-id :user-id]
:do-nothing true}))
;; Increment invite uses
(db/execute! tx
{:update :invites
:set {:uses [:+ :uses 1]}
:where [:= :id (:id invite)]}))
(publish-event! nats
(str "chat.events." community-id)
:member/joined
{:community-id community-id
:user-id user-id
:invite-code code})
(mw/json-response {:status "joined" :community-id community-id}))))))
(defn direct-invite
"POST /api/communities/:cid/invites/direct
Creates a direct invite for a specific user by ID. Admin+ only.
Creates an invite + a notification for the target user."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :cid])
params (:body-params request)
target-uid (:user_id params)]
(check-role! ds community-id user-id "admin")
(when (str/blank? target-uid)
(throw (ex-info "user_id is required"
{:type :ajet.chat/validation-error})))
;; Verify target user exists
(when-not (db/execute-one! ds
{:select [:id]
:from [:users]
:where [:= :id [:cast target-uid :uuid]]})
(throw (ex-info "Target user not found"
{:type :ajet.chat/not-found})))
(let [invite-id (uuid)
code (generate-invite-code)
notif-id (uuid)]
(db/with-transaction [tx ds]
;; Create invite (single use)
(db/execute! tx
{:insert-into :invites
:values [{:id [:cast invite-id :uuid]
:community-id [:cast community-id :uuid]
:created-by [:cast user-id :uuid]
:code code
:max-uses 1}]})
;; Create notification for target user
(db/execute! tx
{:insert-into :notifications
:values [{:id [:cast notif-id :uuid]
:user-id [:cast target-uid :uuid]
:type "invite"
:source-id [:cast invite-id :uuid]}]}))
;; Publish notification event
(publish-event! nats
(str "chat.notifications." target-uid)
:notification/new
{:user-id target-uid
:type "invite"
:invite-code code
:community-id community-id
:invited-by user-id})
(let [invite (db/execute-one! ds
{:select [:*]
:from [:invites]
:where [:= :id [:cast invite-id :uuid]]})]
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
+395
View File
@@ -0,0 +1,395 @@
(ns ajet.chat.api.handlers.messages
"Message handlers: send, list, get, edit, delete, thread, and read-tracking.
Messages use cursor-based pagination. The send handler parses mentions,
creates mention records, creates notifications for @mentioned users,
and publishes events to the correct NATS subject."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.shared.mentions :as mentions]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]
[java.time Instant Duration]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-channel-member! [ds channel-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this channel"
{:type :ajet.chat/forbidden}))))
(defn- get-channel-row [ds channel-id]
(or (db/execute-one! ds
{:select [:*]
:from [:channels]
:where [:= :id [:cast channel-id :uuid]]})
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
(defn- get-message-row [ds message-id]
(or (db/execute-one! ds
{:select [:*]
:from [:messages]
:where [:= :id [:cast message-id :uuid]]})
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
(defn- nats-subject-for-channel
"Determine the NATS subject based on channel type.
Community channels -> chat.events.{community-id}
DM channels -> chat.dm.{channel-id}"
[channel]
(if (:community-id channel)
(str "chat.events." (:community-id channel))
(str "chat.dm." (:id channel))))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
(defn- create-mention-records!
"Parse mentions from message body, create mention rows, and create notifications."
[ds message-id body-md channel-id user-id]
(let [parsed (mentions/parse body-md)]
(doseq [{:keys [type id]} parsed]
(let [mention-id (uuid)]
(db/execute! ds
{:insert-into :mentions
:values [(cond-> {:id [:cast mention-id :uuid]
:message-id [:cast message-id :uuid]
:target-type (name type)}
id (assoc :target-id [:cast id :uuid]))]}))
;; Create notifications for user mentions
(when (and (= type :user) id)
(let [notif-id (uuid)]
(db/execute! ds
{:insert-into :notifications
:values [{:id [:cast notif-id :uuid]
:user-id [:cast id :uuid]
:type "mention"
:source-id [:cast message-id :uuid]}]})))
;; For @here mentions, notify all channel members (except sender)
(when (= type :here)
(let [members (db/execute! ds
{:select [:user-id]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:!= :user-id [:cast user-id :uuid]]]})]
(doseq [member members]
(let [notif-id (uuid)]
(db/execute! ds
{:insert-into :notifications
:values [{:id [:cast notif-id :uuid]
:user-id (:user-id member)
:type "mention"
:source-id [:cast message-id :uuid]}]}))))))))
(defn- create-thread-notifications!
"Notify thread participants (except the sender) when a thread reply is created."
[ds message-id parent-id user-id]
(let [participants (db/execute! ds
{:select-distinct [:user-id]
:from [:messages]
:where [:and
[:or
[:= :id [:cast parent-id :uuid]]
[:= :parent-id [:cast parent-id :uuid]]]
[:!= :user-id [:cast user-id :uuid]]]})]
(doseq [participant participants]
(let [notif-id (uuid)]
(db/execute! ds
{:insert-into :notifications
:values [{:id [:cast notif-id :uuid]
:user-id (:user-id participant)
:type "thread_reply"
:source-id [:cast message-id :uuid]}]})))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-messages
"GET /api/channels/:id/messages?before=<uuid>&after=<uuid>&limit=N
Cursor-based pagination. Default limit 50, max 100."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
channel-id (get-in request [:path-params :id])
params (:query-params request)
before (get params "before")
after (get params "after")
limit (min (or (some-> (get params "limit") parse-long) 50) 100)]
(check-channel-member! ds channel-id user-id)
(let [base-where [:= :channel-id [:cast channel-id :uuid]]
;; Cursor-based pagination using subquery on created_at
where-clause (cond
before
[:and base-where
[:< :created-at
{:select [:created-at]
:from [:messages]
:where [:= :id [:cast before :uuid]]}]]
after
[:and base-where
[:> :created-at
{:select [:created-at]
:from [:messages]
:where [:= :id [:cast after :uuid]]}]]
:else base-where)
order-dir (if after :asc :desc)
messages (db/execute! ds
{:select [:*]
:from [:messages]
:where where-clause
:order-by [[:created-at order-dir]]
:limit limit})
;; Always return newest-last ordering
messages (if (= order-dir :desc)
(vec (reverse messages))
messages)]
(mw/json-response messages))))
(defn send-message
"POST /api/channels/:id/messages
Creates message, parses mentions, creates notifications, publishes event."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
channel-id (get-in request [:path-params :id])
params (:body-params request)
body-md (:body_md params)
parent-id (:parent_id params)]
(check-channel-member! ds channel-id user-id)
(when (str/blank? body-md)
(throw (ex-info "Message body is required"
{:type :ajet.chat/validation-error})))
;; If reply, resolve parent to root (no nested threads)
(let [resolved-parent-id
(when parent-id
(let [parent (get-message-row ds parent-id)]
(or (:parent-id parent) (str (:id parent)))))
message-id (uuid)]
(db/with-transaction [tx ds]
(db/execute! tx
{:insert-into :messages
:values [(cond-> {:id [:cast message-id :uuid]
:channel-id [:cast channel-id :uuid]
:user-id [:cast user-id :uuid]
:body-md body-md}
resolved-parent-id
(assoc :parent-id [:cast resolved-parent-id :uuid]))]})
;; Create mention records and notifications
(create-mention-records! tx message-id body-md channel-id user-id)
;; Create thread notifications if this is a reply
(when resolved-parent-id
(create-thread-notifications! tx message-id resolved-parent-id user-id)))
;; Publish event
(let [channel (get-channel-row ds channel-id)
subject (nats-subject-for-channel channel)
message (get-message-row ds message-id)]
(publish-event! nats subject :message/created
{:message-id message-id
:channel-id channel-id
:user-id user-id
:body-md body-md
:parent-id resolved-parent-id
:community-id (when (:community-id channel)
(str (:community-id channel)))})
(mw/json-response 201 message)))))
(defn get-message
"GET /api/messages/:id
Returns a single message. Requires channel membership."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
message-id (get-in request [:path-params :id])
message (get-message-row ds message-id)]
(check-channel-member! ds (str (:channel-id message)) user-id)
(mw/json-response message)))
(defn edit-message
"PUT /api/messages/:id
Edits message body. Author only, within 1-hour window.
Re-parses mentions and publishes :message/edited."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
message-id (get-in request [:path-params :id])
params (:body-params request)
new-body (:body_md params)
message (get-message-row ds message-id)]
;; Author check
(when (not= (str (:user-id message)) user-id)
(throw (ex-info "Only the author can edit this message"
{:type :ajet.chat/forbidden})))
;; 1-hour edit window
(let [created-at (:created-at message)
created-inst (if (instance? java.time.Instant created-at)
created-at
(.toInstant created-at))
one-hour-later (.plus created-inst (Duration/ofMinutes 60))
now (Instant/now)]
(when (.isAfter now one-hour-later)
(throw (ex-info "Edit window has expired (1 hour)"
{:type :ajet.chat/forbidden}))))
(when (str/blank? new-body)
(throw (ex-info "Message body is required"
{:type :ajet.chat/validation-error})))
(db/with-transaction [tx ds]
;; Update message
(db/execute! tx
{:update :messages
:set {:body-md new-body
:edited-at [:now]}
:where [:= :id [:cast message-id :uuid]]})
;; Delete old mentions and re-create
(db/execute! tx
{:delete-from :mentions
:where [:= :message-id [:cast message-id :uuid]]})
(create-mention-records! tx message-id new-body
(str (:channel-id message)) user-id))
(let [updated (get-message-row ds message-id)
channel (get-channel-row ds (str (:channel-id message)))
subject (nats-subject-for-channel channel)]
(publish-event! nats subject :message/edited
{:message-id message-id
:channel-id (str (:channel-id message))
:user-id user-id
:body-md new-body
:community-id (when (:community-id channel)
(str (:community-id channel)))})
(mw/json-response updated))))
(defn delete-message
"DELETE /api/messages/:id
Deletes message. Author or admin+ of the community."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
message-id (get-in request [:path-params :id])
message (get-message-row ds message-id)
channel (get-channel-row ds (str (:channel-id message)))
is-author (= (str (:user-id message)) user-id)]
;; Check permission: author or admin+ in the community
(when-not is-author
(if (:community-id channel)
(let [member (db/execute-one! ds
{:select [:role]
:from [:community-members]
:where [:and
[:= :community-id (:community-id channel)]
[:= :user-id [:cast user-id :uuid]]]})
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}]
(when (< (get hierarchy role 0) (get hierarchy "admin" 0))
(throw (ex-info "Only the author or an admin can delete this message"
{:type :ajet.chat/forbidden}))))
;; For DMs, only the author can delete
(throw (ex-info "Only the author can delete this message"
{:type :ajet.chat/forbidden}))))
;; CASCADE handles mentions, reactions, attachments
(db/execute! ds
{:delete-from :messages
:where [:= :id [:cast message-id :uuid]]})
(let [subject (nats-subject-for-channel channel)]
(publish-event! nats subject :message/deleted
{:message-id message-id
:channel-id (str (:channel-id message))
:user-id user-id
:community-id (when (:community-id channel)
(str (:community-id channel)))}))
(mw/json-response 204 nil)))
(defn get-thread
"GET /api/messages/:id/thread
Returns all replies to a message, ordered by created_at."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
message-id (get-in request [:path-params :id])
message (get-message-row ds message-id)]
(check-channel-member! ds (str (:channel-id message)) user-id)
;; Get root message + all replies
(let [replies (db/execute! ds
{:select [:*]
:from [:messages]
:where [:= :parent-id [:cast message-id :uuid]]
:order-by [[:created-at :asc]]})]
(mw/json-response {:root message
:replies replies}))))
(defn mark-read
"POST /api/channels/:id/read
Updates last_read_message_id for the user in this channel."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
channel-id (get-in request [:path-params :id])
params (:body-params request)
msg-id (:last_read_message_id params)]
(check-channel-member! ds channel-id user-id)
(when (str/blank? msg-id)
(throw (ex-info "last_read_message_id is required"
{:type :ajet.chat/validation-error})))
(db/execute! ds
{:update :channel-members
:set {:last-read-message-id [:cast msg-id :uuid]}
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(mw/json-response {:status "ok"})))
@@ -0,0 +1,111 @@
(ns ajet.chat.api.handlers.notifications
"Notification handlers: list, mark read, unread count.
Notifications are created by message handlers (mentions, DMs, thread replies)
and invite handlers. This namespace handles reading and managing them."
(:require [ajet.chat.shared.db :as db]
[ajet.chat.api.middleware :as mw]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn list-notifications
"GET /api/notifications?after=<uuid>&limit=N&unread=true
Returns notifications for the authenticated user, newest first."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:query-params request)
after (get params "after")
limit (min (or (some-> (get params "limit") parse-long) 50) 100)
unread (= "true" (get params "unread"))
base-where [:= :user-id [:cast user-id :uuid]]
where-clause (cond-> base-where
unread
(vector :and [:= :read false])
after
(vector :and
[:< :created-at
{:select [:created-at]
:from [:notifications]
:where [:= :id [:cast after :uuid]]}]))
;; Build the full where clause properly
final-where (cond
(and unread after)
[:and
base-where
[:= :read false]
[:< :created-at
{:select [:created-at]
:from [:notifications]
:where [:= :id [:cast after :uuid]]}]]
unread
[:and base-where [:= :read false]]
after
[:and base-where
[:< :created-at
{:select [:created-at]
:from [:notifications]
:where [:= :id [:cast after :uuid]]}]]
:else base-where)
notifications (db/execute! ds
{:select [:*]
:from [:notifications]
:where final-where
:order-by [[:created-at :desc]]
:limit limit})]
(mw/json-response notifications)))
(defn mark-read
"POST /api/notifications/read
Marks specified notifications as read.
Body: {\"notification_ids\": [\"uuid\", ...]}"
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:body-params request)
ids (:notification_ids params)]
(when (or (nil? ids) (empty? ids))
(throw (ex-info "notification_ids is required"
{:type :ajet.chat/validation-error})))
(let [uuid-ids (mapv (fn [id] [:cast id :uuid]) ids)]
(db/execute! ds
{:update :notifications
:set {:read true}
:where [:and
[:= :user-id [:cast user-id :uuid]]
[:in :id uuid-ids]]}))
(mw/json-response {:status "ok" :marked-read (count ids)})))
(defn unread-count
"GET /api/notifications/unread-count
Returns the count of unread notifications."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
result (db/execute-one! ds
{:select [[[:count :*] :count]]
:from [:notifications]
:where [:and
[:= :user-id [:cast user-id :uuid]]
[:= :read false]]})]
(mw/json-response {:unread-count (:count result 0)})))
@@ -0,0 +1,90 @@
(ns ajet.chat.api.handlers.presence
"Presence and heartbeat handlers.
Users send heartbeats every 60 seconds. A user is considered online
if their last heartbeat was within 2 minutes. Heartbeat responses
are immediate; DB update and NATS publishing happen asynchronously."
(:require [clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-membership! [ds community-id user-id]
(or (db/execute-one! ds
{:select [:user-id]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn heartbeat
"POST /api/heartbeat
Reports online status. Responds immediately, updates DB async."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])]
;; Update last_seen_at asynchronously
(future
(try
(db/execute! ds
{:update :users
:set {:last-seen-at [:now]}
:where [:= :id [:cast user-id :uuid]]})
;; Publish presence event to all communities the user belongs to
(let [communities (db/execute! ds
{:select [:community-id]
:from [:community-members]
:where [:= :user-id [:cast user-id :uuid]]})]
(doseq [{:keys [community-id]} communities]
(try
(eventbus/publish! nats
(str "chat.presence." community-id)
:presence/online
{:user-id user-id
:community-id (str community-id)})
(catch Exception e
(log/error e "Failed to publish presence event")))))
(catch Exception e
(log/error e "Failed to process heartbeat for user" user-id))))
;; Respond immediately
(mw/json-response {:status "ok"})))
(defn get-presence
"GET /api/communities/:cid/presence
Returns list of online members (last heartbeat within 2 minutes)."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
_ (check-membership! ds community-id user-id)
;; Online = last_seen_at within 2 minutes
online-members (db/execute! ds
{:select [:u.id :u.username :u.display-name :u.avatar-url
:u.status-text :u.last-seen-at]
:from [[:community-members :cm]]
:join [[:users :u] [:= :u.id :cm.user-id]]
:where [:and
[:= :cm.community-id [:cast community-id :uuid]]
[:!= :u.last-seen-at nil]
[:> :u.last-seen-at
[:raw "now() - interval '2 minutes'"]]]})]
(mw/json-response online-members)))
@@ -0,0 +1,152 @@
(ns ajet.chat.api.handlers.reactions
"Reaction handlers: add, remove, and list reactions on messages.
Reactions use a composite PK (message_id, user_id, emoji) so each
user can only have one reaction per emoji per message."
(:require [clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-channel-member! [ds channel-id user-id]
(or (db/execute-one! ds
{:select [:user-id]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this channel"
{:type :ajet.chat/forbidden}))))
(defn- get-message-row [ds message-id]
(or (db/execute-one! ds
{:select [:*]
:from [:messages]
:where [:= :id [:cast message-id :uuid]]})
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
(defn- get-channel-row [ds channel-id]
(db/execute-one! ds
{:select [:*]
:from [:channels]
:where [:= :id [:cast channel-id :uuid]]}))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn add-reaction
"PUT /api/messages/:id/reactions/:emoji
Adds a reaction. Idempotent (no error if already exists)."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
message-id (get-in request [:path-params :id])
emoji (get-in request [:path-params :emoji])
message (get-message-row ds message-id)
channel-id (str (:channel-id message))]
(check-channel-member! ds channel-id user-id)
;; Upsert: insert if not exists (ON CONFLICT DO NOTHING)
(db/execute! ds
{:insert-into :reactions
:values [{:message-id [:cast message-id :uuid]
:user-id [:cast user-id :uuid]
:emoji emoji}]
:on-conflict [:message-id :user-id :emoji]
:do-nothing true})
(let [channel (get-channel-row ds channel-id)
subject (if (:community-id channel)
(str "chat.events." (:community-id channel))
(str "chat.dm." channel-id))]
(publish-event! nats subject :reaction/added
{:message-id message-id
:channel-id channel-id
:user-id user-id
:emoji emoji}))
(mw/json-response {:status "ok" :emoji emoji})))
(defn remove-reaction
"DELETE /api/messages/:id/reactions/:emoji
Removes a reaction by the current user."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
message-id (get-in request [:path-params :id])
emoji (get-in request [:path-params :emoji])
message (get-message-row ds message-id)
channel-id (str (:channel-id message))]
(let [deleted (db/execute! ds
{:delete-from :reactions
:where [:and
[:= :message-id [:cast message-id :uuid]]
[:= :user-id [:cast user-id :uuid]]
[:= :emoji emoji]]})]
;; Check if anything was deleted
(when (zero? (:next.jdbc/update-count (first deleted) 0))
(throw (ex-info "Reaction not found"
{:type :ajet.chat/not-found}))))
(let [channel (get-channel-row ds channel-id)
subject (if (:community-id channel)
(str "chat.events." (:community-id channel))
(str "chat.dm." channel-id))]
(publish-event! nats subject :reaction/removed
{:message-id message-id
:channel-id channel-id
:user-id user-id
:emoji emoji}))
(mw/json-response 204 nil)))
(defn list-reactions
"GET /api/messages/:id/reactions
Returns reactions grouped by emoji with user lists."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
message-id (get-in request [:path-params :id])
message (get-message-row ds message-id)]
(check-channel-member! ds (str (:channel-id message)) user-id)
(let [reactions (db/execute! ds
{:select [:r.emoji :r.user-id :u.username :u.display-name]
:from [[:reactions :r]]
:join [[:users :u] [:= :u.id :r.user-id]]
:where [:= :r.message-id [:cast message-id :uuid]]
:order-by [[:r.emoji :asc] [:r.created-at :asc]]})
;; Group by emoji
grouped (reduce (fn [acc reaction]
(update acc (:emoji reaction)
(fnil conj [])
{:user-id (str (:user-id reaction))
:username (:username reaction)
:display-name (:display-name reaction)}))
{} reactions)
result (mapv (fn [[emoji users]]
{:emoji emoji
:count (count users)
:users users})
grouped)]
(mw/json-response result))))
+132
View File
@@ -0,0 +1,132 @@
(ns ajet.chat.api.handlers.search
"Full-text search handler using PostgreSQL tsvector.
Supports searching messages, channels, and users with various filters:
community, channel, author, date range, and cursor-based pagination."
(:require [clojure.string :as str]
[ajet.chat.shared.db :as db]
[ajet.chat.api.middleware :as mw]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- search-messages
"Search messages using PostgreSQL full-text search with tsvector.
Only returns messages in channels the user has access to."
[ds user-id query opts]
(let [{:keys [community-id channel-id from after before cursor limit]} opts
;; Build SQL with tsvector search
base-sql (str
"SELECT m.id, m.channel_id, m.user_id, m.body_md, m.created_at, "
"u.username, u.display_name, c.name as channel_name "
"FROM messages m "
"JOIN channels c ON c.id = m.channel_id "
"JOIN users u ON u.id = m.user_id "
"JOIN channel_members cm ON cm.channel_id = m.channel_id AND cm.user_id = ?::uuid "
"WHERE to_tsvector('english', m.body_md) @@ plainto_tsquery('english', ?) ")
params (atom [user-id query])
clauses (atom [])]
(when community-id
(swap! clauses conj "AND c.community_id = ?::uuid")
(swap! params conj community-id))
(when channel-id
(swap! clauses conj "AND m.channel_id = ?::uuid")
(swap! params conj channel-id))
(when from
(swap! clauses conj "AND m.user_id = ?::uuid")
(swap! params conj from))
(when after
(swap! clauses conj "AND m.created_at > ?::timestamptz")
(swap! params conj after))
(when before
(swap! clauses conj "AND m.created_at < ?::timestamptz")
(swap! params conj before))
(when cursor
(swap! clauses conj "AND m.created_at < (SELECT created_at FROM messages WHERE id = ?::uuid)")
(swap! params conj cursor))
(let [full-sql (str base-sql
(str/join " " @clauses)
" ORDER BY m.created_at DESC LIMIT ?")
_ (swap! params conj limit)]
(db/execute-sql! ds (into [full-sql] @params)))))
(defn- search-channels
"Search channels by name substring within user's communities."
[ds user-id query opts]
(let [{:keys [community-id limit]} opts
base-where [:and
[:like [:lower :c.name] [:lower (str "%" query "%")]]
[:in :c.community-id
{:select [:community-id]
:from [:community-members]
:where [:= :user-id [:cast user-id :uuid]]}]]
where-clause (if community-id
[:and base-where
[:= :c.community-id [:cast community-id :uuid]]]
base-where)]
(db/execute! ds
{:select [:c.*]
:from [[:channels :c]]
:where where-clause
:order-by [[:c.name :asc]]
:limit limit})))
(defn- search-users
"Search users by username or display_name substring."
[ds _user-id query opts]
(let [{:keys [limit]} opts]
(db/execute! ds
{:select [:id :username :display-name :avatar-url :status-text]
:from [:users]
:where [:or
[:like [:lower :username] [:lower (str "%" query "%")]]
[:like [:lower :display-name] [:lower (str "%" query "%")]]]
:order-by [[:username :asc]]
:limit limit})))
;;; ---------------------------------------------------------------------------
;;; Handler
;;; ---------------------------------------------------------------------------
(defn search-handler
"GET /api/search?q=term&type=messages|channels|users&community_id=...&channel_id=...&from=...&after=...&before=...&cursor=...&limit=20"
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:query-params request)
query (get params "q")
type (get params "type")
limit (min (or (some-> (get params "limit") parse-long) 20) 100)
opts {:community-id (get params "community_id")
:channel-id (get params "channel_id")
:from (get params "from")
:after (get params "after")
:before (get params "before")
:cursor (get params "cursor")
:limit limit}]
(when (str/blank? query)
(throw (ex-info "Search query is required"
{:type :ajet.chat/validation-error})))
(let [result (case type
"messages" {:messages (search-messages ds user-id query opts)}
"channels" {:channels (search-channels ds user-id query opts)}
"users" {:users (search-users ds user-id query opts)}
;; Default: search all types
{:messages (search-messages ds user-id query opts)
:channels (search-channels ds user-id query opts)
:users (search-users ds user-id query opts)})]
(mw/json-response result))))
+95
View File
@@ -0,0 +1,95 @@
(ns ajet.chat.api.handlers.upload
"File upload handler.
Accepts multipart form data with an image file. Validates content-type
and size, uploads to MinIO/S3, creates an attachment record in the DB,
and returns the attachment metadata."
(:require [clojure.java.io :as io]
[clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.storage :as storage]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-channel-member! [ds channel-id user-id]
(or (db/execute-one! ds
{:select [:user-id]
:from [:channel-members]
:where [:and
[:= :channel-id [:cast channel-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this channel"
{:type :ajet.chat/forbidden}))))
;;; ---------------------------------------------------------------------------
;;; Handler
;;; ---------------------------------------------------------------------------
(defn upload-file
"POST /api/channels/:id/upload
Upload an image file. Returns attachment metadata.
Expects a multipart form with a 'file' part."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
s3 (get-in request [:system :s3])
bucket (get-in request [:system :bucket])
channel-id (get-in request [:path-params :id])]
(check-channel-member! ds channel-id user-id)
;; Extract multipart file from request
;; Ring multipart puts files in :multipart-params or :params with :tempfile
(let [file-param (or (get-in request [:multipart-params "file"])
(get-in request [:params :file]))
_ (when-not file-param
(throw (ex-info "No file uploaded. Send a multipart form with a 'file' field."
{:type :ajet.chat/validation-error})))
{:keys [filename content-type tempfile size]} file-param
size-bytes (or size (when tempfile (.length tempfile)))]
;; Validate content type and size
(storage/validate-upload! content-type size-bytes)
;; Generate storage key
(let [attachment-id (uuid)
key (storage/storage-key attachment-id filename)
file-bytes (if tempfile
(let [data (byte-array size-bytes)]
(with-open [is (io/input-stream tempfile)]
(.read is data))
data)
(throw (ex-info "Uploaded file is missing tempfile"
{:type :ajet.chat/validation-error})))]
;; Upload to S3
(storage/upload! s3 bucket key file-bytes content-type)
;; Create attachment record (not linked to a message yet)
(db/execute! ds
{:insert-into :attachments
:values [{:id [:cast attachment-id :uuid]
:message-id [:cast "00000000-0000-0000-0000-000000000000" :uuid] ;; placeholder
:filename filename
:content-type content-type
:size-bytes size-bytes
:storage-key key}]})
(mw/json-response 201
{:id attachment-id
:filename filename
:content-type content-type
:size-bytes size-bytes
:url (str "/files/" attachment-id "/" filename)})))))
+202
View File
@@ -0,0 +1,202 @@
(ns ajet.chat.api.handlers.users
"User profile and community member management handlers.
Covers: get/update own profile, view other users, list community members,
update member roles/nicknames, and kick members."
(:require [clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-membership! [ds community-id user-id]
(or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (check-membership! ds community-id user-id)
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn get-me
"GET /api/me
Returns the authenticated user's profile."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
user (db/execute-one! ds
{:select [:id :username :display-name :email :avatar-url
:status-text :created-at]
:from [:users]
:where [:= :id [:cast user-id :uuid]]})]
(if user
(mw/json-response user)
(mw/error-response 404 "NOT_FOUND" "User not found"))))
(defn update-me
"PUT /api/me
Updates display_name and/or status_text."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
params (:body-params request)
updates (cond-> {}
(contains? params :display_name) (assoc :display-name (:display_name params))
(contains? params :status_text) (assoc :status-text (:status_text params)))]
(when (seq updates)
(db/execute! ds
{:update :users
:set updates
:where [:= :id [:cast user-id :uuid]]}))
(let [user (db/execute-one! ds
{:select [:id :username :display-name :email :avatar-url
:status-text :created-at]
:from [:users]
:where [:= :id [:cast user-id :uuid]]})]
(mw/json-response user))))
(defn get-user
"GET /api/users/:id
Returns public profile for any user."
[request]
(let [_ (require-user! request)
ds (get-in request [:system :ds])
user-id (get-in request [:path-params :id])
user (db/execute-one! ds
{:select [:id :username :display-name :avatar-url :status-text :created-at]
:from [:users]
:where [:= :id [:cast user-id :uuid]]})]
(if user
(mw/json-response user)
(mw/error-response 404 "NOT_FOUND" "User not found"))))
(defn list-community-members
"GET /api/communities/:cid/members
Lists all members of a community with roles and nicknames."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
_ (check-membership! ds community-id user-id)
members (db/execute! ds
{:select [:u.id :u.username :u.display-name :u.avatar-url
:u.status-text :cm.role :cm.nickname :cm.joined-at]
:from [[:community-members :cm]]
:join [[:users :u] [:= :u.id :cm.user-id]]
:where [:= :cm.community-id [:cast community-id :uuid]]
:order-by [[:u.username :asc]]})]
(mw/json-response members)))
(defn update-community-member
"PUT /api/communities/:cid/members/:uid
Update a member's nickname or role. Admin+ for nickname, Owner for role."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :cid])
target-uid (get-in request [:path-params :uid])
params (:body-params request)
actor-member (check-role! ds community-id user-id "admin")]
;; Verify target is a member
(let [target-member (check-membership! ds community-id target-uid)]
;; Role changes require owner permission
(when (contains? params :role)
(when (not= (:role actor-member) "owner")
(throw (ex-info "Only the owner can change roles"
{:type :ajet.chat/forbidden})))
;; Cannot change owner's role
(when (= (:role target-member) "owner")
(throw (ex-info "Cannot change the owner's role"
{:type :ajet.chat/forbidden}))))
(let [updates (cond-> {}
(contains? params :nickname) (assoc :nickname (:nickname params))
(contains? params :role) (assoc :role (:role params)))]
(when (seq updates)
(db/execute! ds
{:update :community-members
:set updates
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]}))
(let [updated (db/execute-one! ds
{:select [:u.id :u.username :u.display-name :cm.role :cm.nickname]
:from [[:community-members :cm]]
:join [[:users :u] [:= :u.id :cm.user-id]]
:where [:and
[:= :cm.community-id [:cast community-id :uuid]]
[:= :cm.user-id [:cast target-uid :uuid]]]})]
(mw/json-response updated))))))
(defn kick-member
"DELETE /api/communities/:cid/members/:uid
Removes a member from the community. Admin+. Cannot kick owner."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
community-id (get-in request [:path-params :cid])
target-uid (get-in request [:path-params :uid])]
(check-role! ds community-id user-id "admin")
;; Cannot kick owner
(let [target-member (check-membership! ds community-id target-uid)]
(when (= (:role target-member) "owner")
(throw (ex-info "Cannot kick the community owner"
{:type :ajet.chat/forbidden}))))
;; Remove from all channels in this community
(db/execute-sql! ds
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
target-uid community-id])
;; Remove community membership
(db/execute! ds
{:delete-from :community-members
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast target-uid :uuid]]]})
(publish-event! nats
(str "chat.events." community-id)
:member/kicked
{:community-id community-id
:user-id target-uid
:kicked-by user-id})
(mw/json-response 204 nil)))
+219
View File
@@ -0,0 +1,219 @@
(ns ajet.chat.api.handlers.webhooks
"Webhook handlers: create, list, delete, and incoming message posting.
Webhooks allow external services to post messages to channels.
Each webhook has a secret token used for authentication."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]
[ajet.chat.shared.eventbus :as eventbus]
[ajet.chat.api.middleware :as mw])
(:import [java.util UUID]
[java.security SecureRandom MessageDigest]
[java.util Base64]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- uuid [] (str (UUID/randomUUID)))
(defn- require-user! [request]
(or (:user-id request)
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
(defn- check-role! [ds community-id user-id required-role]
(let [member (or (db/execute-one! ds
{:select [:*]
:from [:community-members]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})
(throw (ex-info "Not a member of this community"
{:type :ajet.chat/forbidden})))
role (:role member)
hierarchy {"owner" 3 "admin" 2 "member" 1}
has-level (get hierarchy role 0)
need-level (get hierarchy required-role 0)]
(when (< has-level need-level)
(throw (ex-info (str "Requires " required-role " role or higher")
{:type :ajet.chat/forbidden})))
member))
(defn- generate-token
"Generate a random token string."
[]
(let [bytes (byte-array 32)
sr (SecureRandom.)]
(.nextBytes sr bytes)
(.encodeToString (Base64/getUrlEncoder) bytes)))
(defn- hash-token
"SHA-256 hash a token for storage."
[token]
(let [md (MessageDigest/getInstance "SHA-256")
bytes (.digest md (.getBytes token "UTF-8"))]
(.encodeToString (Base64/getUrlEncoder) bytes)))
(defn- publish-event! [nats subject event-type payload]
(try
(eventbus/publish! nats subject event-type payload)
(catch Exception e
(log/error e "Failed to publish event" event-type "to" subject))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn create-webhook
"POST /api/communities/:cid/webhooks
Creates a webhook. Admin+ only. Returns the token once."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])
params (:body-params request)
name-v (:name params)
channel-id (:channel_id params)
avatar-url (:avatar_url params)]
(check-role! ds community-id user-id "admin")
(when (str/blank? name-v)
(throw (ex-info "Webhook name is required"
{:type :ajet.chat/validation-error})))
(when (str/blank? channel-id)
(throw (ex-info "channel_id is required"
{:type :ajet.chat/validation-error})))
;; Verify channel exists and belongs to community
(let [channel (db/execute-one! ds
{:select [:id :community-id]
:from [:channels]
:where [:and
[:= :id [:cast channel-id :uuid]]
[:= :community-id [:cast community-id :uuid]]]})]
(when-not channel
(throw (ex-info "Channel not found in this community"
{:type :ajet.chat/not-found}))))
(let [webhook-id (uuid)
token (generate-token)
token-h (hash-token token)]
(db/execute! ds
{:insert-into :webhooks
:values [(cond-> {:id [:cast webhook-id :uuid]
:community-id [:cast community-id :uuid]
:channel-id [:cast channel-id :uuid]
:name name-v
:token-hash token-h
:created-by [:cast user-id :uuid]}
avatar-url (assoc :avatar-url avatar-url))]})
(let [webhook (db/execute-one! ds
{:select [:id :community-id :channel-id :name :avatar-url :created-at]
:from [:webhooks]
:where [:= :id [:cast webhook-id :uuid]]})]
;; Return the token once (not stored in plaintext)
(mw/json-response 201 (assoc webhook :token token))))))
(defn list-webhooks
"GET /api/communities/:cid/webhooks
Lists webhooks (without tokens). Admin+ only."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
community-id (get-in request [:path-params :cid])]
(check-role! ds community-id user-id "admin")
(let [webhooks (db/execute! ds
{:select [:id :community-id :channel-id :name :avatar-url
:created-by :created-at]
:from [:webhooks]
:where [:= :community-id [:cast community-id :uuid]]
:order-by [[:created-at :desc]]})]
(mw/json-response webhooks))))
(defn delete-webhook
"DELETE /api/webhooks/:id
Deletes a webhook. Admin+ of the webhook's community."
[request]
(let [user-id (require-user! request)
ds (get-in request [:system :ds])
webhook-id (get-in request [:path-params :id])
webhook (or (db/execute-one! ds
{:select [:*]
:from [:webhooks]
:where [:= :id [:cast webhook-id :uuid]]})
(throw (ex-info "Webhook not found"
{:type :ajet.chat/not-found})))]
(check-role! ds (str (:community-id webhook)) user-id "admin")
(db/execute! ds
{:delete-from :webhooks
:where [:= :id [:cast webhook-id :uuid]]})
(mw/json-response 204 nil)))
(defn incoming-webhook
"POST /api/webhooks/:id/incoming
Post a message via webhook. Authenticates via Bearer token."
[request]
(let [ds (get-in request [:system :ds])
nats (get-in request [:system :nats])
webhook-id (get-in request [:path-params :id])
params (:body-params request)
content (:content params)
username (:username params)
avatar-url (:avatar_url params)]
;; Authenticate via Bearer token
(let [auth-header (get-in request [:headers "authorization"])
token (when (and auth-header (str/starts-with? auth-header "Bearer "))
(subs auth-header 7))
_ (when-not token
(throw (ex-info "Authorization required"
{:type :ajet.chat/forbidden})))
token-h (hash-token token)
webhook (or (db/execute-one! ds
{:select [:*]
:from [:webhooks]
:where [:and
[:= :id [:cast webhook-id :uuid]]
[:= :token-hash token-h]]})
(throw (ex-info "Invalid webhook token"
{:type :ajet.chat/forbidden})))]
(when (str/blank? content)
(throw (ex-info "content is required"
{:type :ajet.chat/validation-error})))
;; Create message in the webhook's channel
(let [message-id (uuid)
channel-id (str (:channel-id webhook))
community-id (str (:community-id webhook))
body-md (str content)]
(db/execute! ds
{:insert-into :messages
:values [{:id [:cast message-id :uuid]
:channel-id [:cast channel-id :uuid]
;; webhook messages have nil user_id (webhook is the author)
:body-md body-md}]})
(publish-event! nats
(str "chat.events." community-id)
:message/created
{:message-id message-id
:channel-id channel-id
:community-id community-id
:body-md body-md
:webhook-id webhook-id
:webhook-name (or username (:name webhook))
:avatar-url (or avatar-url (:avatar-url webhook))})
(mw/json-response 204 nil)))))
+177
View File
@@ -0,0 +1,177 @@
(ns ajet.chat.api.middleware
"Ring middleware pipeline for the API service.
Provides:
- Exception handling (catch-all -> 500)
- User context extraction from Auth GW headers
- Ban check for community-scoped endpoints
- Mute check for write endpoints
- JSON response helpers"
(:require [clojure.data.json :as json]
[clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db]))
;;; ---------------------------------------------------------------------------
;;; Response Helpers
;;; ---------------------------------------------------------------------------
(defn json-response
"Build a Ring response with JSON content type."
([status body]
{:status status
:headers {"Content-Type" "application/json"}
:body (json/write-str body)})
([body]
(json-response 200 body)))
(defn error-response
"Build a JSON error response.
code: string like \"NOT_FOUND\", \"FORBIDDEN\"
message: human-readable string
details: optional map of additional context"
([status code message]
(error-response status code message {}))
([status code message details]
(json-response status {:error {:code code
:message message
:details details}})))
;;; ---------------------------------------------------------------------------
;;; Middleware: Exception Handler
;;; ---------------------------------------------------------------------------
(defn wrap-exception-handler
"Catch-all exception handler. Returns 500 JSON error for unhandled exceptions."
[handler]
(fn [request]
(try
(handler request)
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)
typ (:type data)]
(cond
(= typ :ajet.chat/validation-error)
(error-response 422 "VALIDATION_ERROR" (ex-message e) (dissoc data :type))
(= typ :ajet.chat/not-found)
(error-response 404 "NOT_FOUND" (ex-message e) (dissoc data :type))
(= typ :ajet.chat/forbidden)
(error-response 403 "FORBIDDEN" (ex-message e) (dissoc data :type))
(= typ :ajet.chat/conflict)
(error-response 409 "CONFLICT" (ex-message e) (dissoc data :type))
(= typ :ajet.chat/gone)
(error-response 410 "GONE" (ex-message e) (dissoc data :type))
:else
(do
(log/error e "Unhandled exception" (pr-str data))
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
(catch Exception e
(log/error e "Unhandled exception")
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
;;; ---------------------------------------------------------------------------
;;; Middleware: User Context
;;; ---------------------------------------------------------------------------
(defn wrap-user-context
"Extract X-User-Id, X-User-Role, X-Community-Id from request headers
into the request map as :user-id, :user-role, :community-id."
[handler]
(fn [request]
(let [headers (:headers request)
user-id (get headers "x-user-id")
user-role (get headers "x-user-role")
community-id (get headers "x-community-id")]
(handler (cond-> request
user-id (assoc :user-id user-id)
user-role (assoc :user-role user-role)
community-id (assoc :community-id community-id))))))
;;; ---------------------------------------------------------------------------
;;; Middleware: System Injection
;;; ---------------------------------------------------------------------------
(defn wrap-system
"Inject system components into the request map under :system."
[handler system]
(fn [request]
(handler (assoc request :system system))))
;;; ---------------------------------------------------------------------------
;;; Middleware: Ban Check
;;; ---------------------------------------------------------------------------
(defn wrap-ban-check
"For community-scoped endpoints, check if the user is banned.
Rejects with 403 if banned. Requires :user-id and :community-id on request."
[handler]
(fn [request]
(let [user-id (:user-id request)
community-id (or (:community-id request)
(get-in request [:path-params :cid]))
ds (get-in request [:system :ds])]
(if (and user-id community-id ds)
(let [banned? (db/execute-one! ds
{:select [[:1 :exists]]
:from [:bans]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]]})]
(if banned?
(error-response 403 "BANNED" "You are banned from this community")
(handler request)))
(handler request)))))
;;; ---------------------------------------------------------------------------
;;; Middleware: Mute Check
;;; ---------------------------------------------------------------------------
(defn wrap-mute-check
"For write endpoints (POST/PUT/DELETE on messages, reactions, etc.),
check if the user is muted. Rejects with 403 if actively muted."
[handler]
(fn [request]
(let [user-id (:user-id request)
community-id (or (:community-id request)
(get-in request [:path-params :cid]))
ds (get-in request [:system :ds])]
(if (and user-id community-id ds)
(let [muted? (db/execute-one! ds
{:select [[:1 :exists]]
:from [:mutes]
:where [:and
[:= :community-id [:cast community-id :uuid]]
[:= :user-id [:cast user-id :uuid]]
[:or
[:= :expires-at nil]
[:> :expires-at [:now]]]]})]
(if muted?
(error-response 403 "MUTED" "You are muted in this community")
(handler request)))
(handler request)))))
;;; ---------------------------------------------------------------------------
;;; Middleware: JSON Body Parsing
;;; ---------------------------------------------------------------------------
(defn wrap-json-body
"Parse JSON request body into :body-params."
[handler]
(fn [request]
(let [content-type (get-in request [:headers "content-type"] "")]
(if (and (:body request)
(or (.contains content-type "application/json")
(.contains content-type "text/json")))
(try
(let [body-str (slurp (:body request))
parsed (when-not (str/blank? body-str)
(json/read-str body-str :key-fn keyword))]
(handler (assoc request :body-params (or parsed {}))))
(catch Exception _
(error-response 400 "INVALID_JSON" "Request body is not valid JSON")))
(handler (assoc request :body-params {}))))))
+177
View File
@@ -0,0 +1,177 @@
(ns ajet.chat.api.routes
"Reitit router with all API endpoint groups.
All routes organized by feature domain. Middleware pipeline applied
in the order specified by the PRD."
(:require [reitit.ring :as ring]
[reitit.ring.middleware.parameters :as parameters]
[reitit.ring.middleware.muuntaja :as muuntaja]
[ajet.chat.api.middleware :as mw]
[ajet.chat.shared.logging :as logging]
[ajet.chat.api.handlers.communities :as communities]
[ajet.chat.api.handlers.channels :as channels]
[ajet.chat.api.handlers.categories :as categories]
[ajet.chat.api.handlers.messages :as messages]
[ajet.chat.api.handlers.reactions :as reactions]
[ajet.chat.api.handlers.dms :as dms]
[ajet.chat.api.handlers.users :as users]
[ajet.chat.api.handlers.notifications :as notifications]
[ajet.chat.api.handlers.presence :as presence]
[ajet.chat.api.handlers.search :as search]
[ajet.chat.api.handlers.invites :as invites]
[ajet.chat.api.handlers.webhooks :as webhooks]
[ajet.chat.api.handlers.commands :as commands]
[ajet.chat.api.handlers.upload :as upload]
[ajet.chat.api.handlers.health :as health]
[ajet.chat.api.handlers.admin :as admin]))
(defn router
"Build the reitit router with all API routes."
[]
(ring/router
[["/api"
;; Health check - no auth, no ban/mute check
["/health" {:get {:handler health/health-check}}]
;; Communities
["/communities"
["" {:get {:handler communities/list-communities}
:post {:handler communities/create-community}}]
["/:id" {:get {:handler communities/get-community}
:put {:handler communities/update-community}
:delete {:handler communities/delete-community}}]
;; Community-scoped channels
["/:cid/channels" {:get {:handler channels/list-channels
:middleware [mw/wrap-ban-check]}
:post {:handler channels/create-channel
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
;; Community-scoped categories
["/:cid/categories" {:get {:handler categories/list-categories
:middleware [mw/wrap-ban-check]}
:post {:handler categories/create-category
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
;; Community members
["/:cid/members"
["" {:get {:handler users/list-community-members
:middleware [mw/wrap-ban-check]}}]
["/:uid" {:put {:handler users/update-community-member
:middleware [mw/wrap-ban-check]}
:delete {:handler users/kick-member
:middleware [mw/wrap-ban-check]}}]]
;; Presence
["/:cid/presence" {:get {:handler presence/get-presence
:middleware [mw/wrap-ban-check]}}]
;; Invites
["/:cid/invites"
["" {:post {:handler invites/create-invite
:middleware [mw/wrap-ban-check]}
:get {:handler invites/list-invites
:middleware [mw/wrap-ban-check]}}]
["/direct" {:post {:handler invites/direct-invite
:middleware [mw/wrap-ban-check]}}]]
;; Webhooks
["/:cid/webhooks" {:post {:handler webhooks/create-webhook
:middleware [mw/wrap-ban-check]}
:get {:handler webhooks/list-webhooks
:middleware [mw/wrap-ban-check]}}]]
;; Channels (by channel ID)
["/channels/:id"
["" {:get {:handler channels/get-channel}
:put {:handler channels/update-channel}
:delete {:handler channels/delete-channel}}]
["/join" {:post {:handler channels/join-channel
:middleware [mw/wrap-ban-check]}}]
["/leave" {:post {:handler channels/leave-channel}}]
["/members" {:get {:handler channels/list-channel-members}}]
["/messages" {:get {:handler messages/list-messages}
:post {:handler messages/send-message
:middleware [mw/wrap-mute-check]}}]
["/upload" {:post {:handler upload/upload-file}}]
["/read" {:post {:handler messages/mark-read}}]]
;; Categories (by category ID)
["/categories/:id" {:put {:handler categories/update-category}
:delete {:handler categories/delete-category}}]
;; Messages (by message ID)
["/messages/:id"
["" {:get {:handler messages/get-message}
:put {:handler messages/edit-message
:middleware [mw/wrap-mute-check]}
:delete {:handler messages/delete-message}}]
["/thread" {:get {:handler messages/get-thread}}]
["/reactions"
["" {:get {:handler reactions/list-reactions}}]
["/:emoji" {:put {:handler reactions/add-reaction
:middleware [mw/wrap-mute-check]}
:delete {:handler reactions/remove-reaction}}]]]
;; DMs
["/dms"
["" {:get {:handler dms/list-dms}
:post {:handler dms/create-dm}}]
["/group" {:post {:handler dms/create-group-dm}}]]
;; Users & Profile
["/me" {:get {:handler users/get-me}
:put {:handler users/update-me}}]
["/users/:id" {:get {:handler users/get-user}}]
;; Notifications
["/notifications"
["" {:get {:handler notifications/list-notifications}}]
["/read" {:post {:handler notifications/mark-read}}]
["/unread-count" {:get {:handler notifications/unread-count}}]]
;; Presence & Heartbeat
["/heartbeat" {:post {:handler presence/heartbeat}}]
;; Search
["/search" {:get {:handler search/search-handler}}]
;; Invites (by invite ID / code)
["/invites/:id" {:delete {:handler invites/revoke-invite}}]
["/invites/:code/accept" {:post {:handler invites/accept-invite}}]
;; Webhooks (by webhook ID)
["/webhooks/:id"
["" {:delete {:handler webhooks/delete-webhook}}]
["/incoming" {:post {:handler webhooks/incoming-webhook}}]]
;; Slash commands
["/commands" {:post {:handler commands/execute-command}}]
;; Admin — OAuth provider management
["/admin/oauth-providers"
["" {:get {:handler admin/list-providers}
:post {:handler admin/create-provider}}]
["/:id" {:put {:handler admin/update-provider}
:delete {:handler admin/delete-provider}}]]]]
{:data {:middleware [parameters/parameters-middleware]}}))
(defn app
"Build the full Ring handler with middleware stack."
[system]
(let [handler (ring/ring-handler
(router)
;; Default handler for unmatched routes
(ring/create-default-handler
{:not-found
(constantly (mw/error-response 404 "NOT_FOUND" "Route not found"))
:method-not-allowed
(constantly (mw/error-response 405 "METHOD_NOT_ALLOWED" "Method not allowed"))}))]
(-> handler
mw/wrap-user-context
mw/wrap-json-body
(mw/wrap-system system)
logging/wrap-request-logging
mw/wrap-exception-handler)))
+12
View File
@@ -0,0 +1,12 @@
FROM clojure:temurin-21-tools-deps AS builder
WORKDIR /app
COPY deps.edn build.clj ./
COPY shared/ shared/
COPY auth-gw/ auth-gw/
RUN clj -T:build uber :module auth-gw
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/auth-gw/target/auth-gw.jar app.jar
EXPOSE 3000
CMD ["java", "-jar", "app.jar"]
+8
View File
@@ -0,0 +1,8 @@
{:tasks
{test
{:doc "Run all auth-gw module tests"
:task (shell {:dir ".."} "bb test:auth-gw")}
test:integration
{:doc "Run auth-gw integration tests"
:task (shell {:dir ".."} "bb test:auth-gw:integration")}}}
+4 -1
View File
@@ -3,7 +3,10 @@
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"}}
ajet/chat-shared {:local/root "../shared"}
hiccup/hiccup {:mvn/version "2.0.0-RC4"}
at.favre.lib/bcrypt {:mvn/version "0.10.2"}
ring/ring-codec {:mvn/version "1.2.0"}}
:aliases
{:run {:main-opts ["-m" "ajet.chat.auth-gw.core"]}
:dev {:extra-paths ["dev"]
+23
View File
@@ -0,0 +1,23 @@
{:server {:host "0.0.0.0" :port 3000}
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
:user "ajet" :password "ajet_dev" :pool-size 5}
:oauth {:github {:client-id "" :client-secret "" :enabled false}
:gitea {:client-id "" :client-secret "" :base-url "" :enabled false}
:oidc {:client-id "" :client-secret "" :issuer-url "" :enabled false}}
:services {:api {:host "localhost" :port 3001}
:web-sm {:host "localhost" :port 3002}
:tui-sm {:host "localhost" :port 3003}}
:session {:ttl-days 30
:cookie-name "ajet_session"
:cookie-secure false}
:rate-limit {:enabled true}
:cors {:allowed-origins ["http://localhost:3000" "http://localhost:3002"]
:allowed-methods [:get :post :put :delete :options]
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
:max-age 86400}
:profiles
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
:password "ajet_test"}}
:prod {:session {:cookie-secure true}
:cors {:allowed-origins ["https://chat.example.com"]}}}}
+437
View File
@@ -0,0 +1,437 @@
(ns ajet.chat.auth-gw.auth
"Authentication — session and token validation, creation, and destruction.
Token format: 32 random bytes -> base64url encoded (43 chars).
Stored as bcrypt hash in the database.
Sessions use rolling expiry (default 30 days), extended on each valid request."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.shared.db :as db])
(:import [at.favre.lib.crypto.bcrypt BCrypt]
[java.security SecureRandom]
[java.util Base64]
[java.time Instant Duration]
[java.sql Timestamp]))
;;; ---------------------------------------------------------------------------
;;; Token generation
;;; ---------------------------------------------------------------------------
(def ^:private secure-random (SecureRandom.))
(def ^:private token-byte-length 32)
(def ^:private bcrypt-cost 12)
(defn- generate-token-bytes
"Generate cryptographically random bytes."
^bytes []
(let [buf (byte-array token-byte-length)]
(.nextBytes secure-random buf)
buf))
(defn- base64url-encode
"Encode bytes to base64url string (no padding)."
^String [^bytes data]
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) data))
(defn- base64url-decode
"Decode a base64url string to bytes."
^bytes [^String s]
(.decode (Base64/getUrlDecoder) s))
(defn- bcrypt-hash-bytes
"Hash raw token chars with bcrypt, returning the hash as a string."
^String [^chars token-chars]
(.hashToString (BCrypt/withDefaults) bcrypt-cost token-chars))
(defn- bcrypt-verify
"Verify a raw token string against a bcrypt hash. Returns true if match."
[^String token ^String hash]
(let [result (.verify (BCrypt/verifyer) (.getBytes token "UTF-8") (.getBytes hash "UTF-8"))]
(.verified result)))
(defn- generate-raw-token
"Generate a random token. Returns the raw base64url-encoded string."
[]
(base64url-encode (generate-token-bytes)))
;;; ---------------------------------------------------------------------------
;;; Timestamp helpers
;;; ---------------------------------------------------------------------------
(defn- now-instant []
(Instant/now))
(defn- instant->timestamp [^Instant inst]
(Timestamp/from inst))
(defn- session-expiry
"Calculate session expiry from now + ttl-days."
[ttl-days]
(instant->timestamp (.plus (now-instant) (Duration/ofDays ttl-days))))
(defn- expired?
"Check if a timestamp is in the past."
[^Timestamp ts]
(.before ts (instant->timestamp (now-instant))))
;;; ---------------------------------------------------------------------------
;;; Session validation
;;; ---------------------------------------------------------------------------
(defn validate-session
"Extract and validate the session cookie.
Looks up the session by token hash in the sessions table, verifies the
bcrypt hash matches, and checks expiry.
Returns a map with user info on success:
{:user-id ... :user-role ... :session-id ...}
Returns nil on failure (invalid/expired/missing)."
[ds cookie-value]
(when (and cookie-value (not (empty? cookie-value)))
(try
;; Look up all non-expired sessions and verify against each
;; In practice, we rely on bcrypt verification being the gate
(let [sessions (db/execute! ds
{:select [:s.id :s.user-id :s.token-hash :s.expires-at
:u.username :u.display-name :u.email]
:from [[:sessions :s]]
:join [[:users :u] [:= :s.user-id :u.id]]
:where [:> :s.expires-at (instant->timestamp (now-instant))]})]
;; Find the session whose hash matches the provided token
(some (fn [session]
(when (bcrypt-verify cookie-value (:token-hash session))
{:session-id (:id session)
:user-id (str (:user-id session))
:username (:username session)
:display-name (:display-name session)
:email (:email session)}))
sessions))
(catch Exception e
(log/error e "Error validating session")
nil))))
(defn validate-api-token
"Extract and validate a Bearer token from the Authorization header.
Looks up the token in the api_tokens table, verifies bcrypt hash,
checks expiry.
Returns a map on success:
{:api-user-id ... :user-id ... :scopes [...]}
Returns nil on failure."
[ds authorization-header]
(when authorization-header
(let [parts (str/split authorization-header #"\s+" 2)]
(when (and (= "Bearer" (first parts)) (second parts))
(let [raw-token (second parts)]
(try
(let [tokens (db/execute! ds
{:select [:at.id :at.api-user-id :at.token-hash
:at.expires-at :at.scopes
:au.user-id :au.community-id]
:from [[:api_tokens :at]]
:join [[:api_users :au] [:= :at.api-user-id :au.id]]
:where [:or
[:= :at.expires-at nil]
[:> :at.expires-at (instant->timestamp (now-instant))]]})]
(some (fn [token-row]
(when (bcrypt-verify raw-token (:token-hash token-row))
{:api-user-id (str (:api-user-id token-row))
:user-id (str (:user-id token-row))
:community-id (str (:community-id token-row))
:scopes (:scopes token-row)}))
tokens))
(catch Exception e
(log/error e "Error validating API token")
nil)))))))
(defn validate-webhook-token
"Validate a Bearer token for webhook incoming requests.
Looks up the token in the webhooks table and verifies bcrypt hash.
Returns a map on success:
{:webhook-id ... :community-id ... :channel-id ...}
Returns nil on failure."
[ds authorization-header]
(when authorization-header
(let [parts (str/split authorization-header #"\s+" 2)]
(when (and (= "Bearer" (first parts)) (second parts))
(let [raw-token (second parts)]
(try
(let [webhooks (db/execute! ds
{:select [:id :community-id :channel-id
:token-hash :name]
:from [:webhooks]})]
(some (fn [wh]
(when (bcrypt-verify raw-token (:token-hash wh))
{:webhook-id (str (:id wh))
:community-id (str (:community-id wh))
:channel-id (str (:channel-id wh))
:webhook-name (:name wh)}))
webhooks))
(catch Exception e
(log/error e "Error validating webhook token")
nil)))))))
;;; ---------------------------------------------------------------------------
;;; Session TTL extension
;;; ---------------------------------------------------------------------------
(defn extend-session-ttl!
"Asynchronously extend the session's expires_at. Fire-and-forget via future."
[ds session-id ttl-days]
(future
(try
(db/execute! ds
{:update :sessions
:set {:expires-at (session-expiry ttl-days)}
:where [:= :id session-id]})
(catch Exception e
(log/warn e "Failed to extend session TTL for" session-id)))))
;;; ---------------------------------------------------------------------------
;;; Session creation
;;; ---------------------------------------------------------------------------
(defn create-session!
"Create a new session for a user.
Generates a random token, bcrypt hashes it, inserts into sessions table.
Returns the raw token (to be set as cookie value)."
[ds user-id ttl-days]
(let [raw-token (generate-raw-token)
token-hash (bcrypt-hash-bytes (.toCharArray raw-token))
session-id (java.util.UUID/randomUUID)
now-ts (instant->timestamp (now-instant))
expires (session-expiry ttl-days)]
(db/execute! ds
{:insert-into :sessions
:values [{:id session-id
:user-id (if (instance? java.util.UUID user-id)
user-id
(java.util.UUID/fromString (str user-id)))
:token-hash token-hash
:expires-at expires
:created-at now-ts}]})
(log/info "Created session" session-id "for user" user-id)
raw-token))
;;; ---------------------------------------------------------------------------
;;; Session destruction
;;; ---------------------------------------------------------------------------
(defn destroy-session!
"Destroy a session by deleting from the database.
Returns a Ring response map fragment with cleared cookie."
[ds cookie-value cookie-name cookie-secure?]
(when (and cookie-value (not (empty? cookie-value)))
;; Find and delete matching session
(try
(let [sessions (db/execute! ds
{:select [:id :token-hash]
:from [:sessions]})]
(doseq [session sessions]
(when (bcrypt-verify cookie-value (:token-hash session))
(db/execute! ds
{:delete-from :sessions
:where [:= :id (:id session)]})
(log/info "Destroyed session" (:id session)))))
(catch Exception e
(log/warn e "Error destroying session"))))
;; Return cleared cookie header
{:cookies {cookie-name {:value ""
:path "/"
:max-age 0
:http-only true
:secure cookie-secure?
:same-site :lax}}})
;;; ---------------------------------------------------------------------------
;;; User lookup / creation for OAuth
;;; ---------------------------------------------------------------------------
(defn find-user-by-oauth
"Look up a user by OAuth provider and provider user ID.
Returns the user map or nil."
[ds provider provider-user-id]
(db/execute-one! ds
{:select [:u.id :u.username :u.display-name :u.email :u.avatar-url :u.created-at]
:from [[:users :u]]
:join [[:oauth_accounts :oa] [:= :oa.user-id :u.id]]
:where [:and
[:= :oa.provider (name provider)]
[:= :oa.provider-user-id (str provider-user-id)]]}))
(defn create-user-from-oauth!
"Create a new user and linked OAuth account from provider profile data.
profile: {:username, :display-name, :email, :avatar-url,
:provider, :provider-user-id, :provider-username}
Returns the created user map."
[ds profile]
(let [user-id (java.util.UUID/randomUUID)
oauth-id (java.util.UUID/randomUUID)
now-ts (instant->timestamp (now-instant))
;; Ensure unique username — append random suffix if collision
base-username (:username profile)
user (db/execute-one! ds
{:insert-into :users
:values [{:id user-id
:username base-username
:display-name (or (:display-name profile) base-username)
:email (or (:email profile) "")
:avatar-url (:avatar-url profile)
:created-at now-ts}]
:returning [:*]})]
;; Create OAuth account link
(db/execute! ds
{:insert-into :oauth_accounts
:values [{:id oauth-id
:user-id user-id
:provider (name (:provider profile))
:provider-user-id (str (:provider-user-id profile))
:provider-username (:provider-username profile)
:created-at now-ts}]})
(log/info "Created user" user-id "via OAuth" (name (:provider profile))
"provider-user-id" (:provider-user-id profile))
user))
(defn count-users
"Return the total number of users in the database."
[ds]
(let [result (db/execute-one! ds
{:select [[[:count :*] :cnt]]
:from [:users]})]
(or (:cnt result) 0)))
(defn find-user-by-username
"Look up a user by username. Returns the user map or nil."
[ds username]
(db/execute-one! ds
{:select [:*]
:from [:users]
:where [:= :username username]}))
;;; ---------------------------------------------------------------------------
;;; OAuth Provider queries (DB-stored providers)
;;; ---------------------------------------------------------------------------
(defn list-oauth-providers
"Return all enabled OAuth providers from the database, ordered by sort_order."
[ds]
(db/execute! ds
{:select [:*]
:from [:oauth-providers]
:where [:= :enabled true]
:order-by [[:sort-order :asc] [:created-at :asc]]}))
(defn list-all-oauth-providers
"Return all OAuth providers from the database (including disabled)."
[ds]
(db/execute! ds
{:select [:*]
:from [:oauth-providers]
:order-by [[:sort-order :asc] [:created-at :asc]]}))
(defn count-oauth-providers
"Count enabled OAuth providers."
[ds]
(let [result (db/execute-one! ds
{:select [[[:count :*] :cnt]]
:from [:oauth-providers]
:where [:= :enabled true]})]
(or (:cnt result) 0)))
(defn find-oauth-provider-by-slug
"Look up an OAuth provider by slug. Returns the provider row or nil."
[ds slug]
(db/execute-one! ds
{:select [:*]
:from [:oauth-providers]
:where [:and [:= :slug slug] [:= :enabled true]]}))
(defn insert-oauth-provider!
"Insert a new OAuth provider. Returns the created row."
[ds provider-map]
(db/execute-one! ds
{:insert-into :oauth-providers
:values [provider-map]
:returning [:*]}))
(defn delete-oauth-provider!
"Delete an OAuth provider by ID."
[ds id]
(db/execute! ds
{:delete-from :oauth-providers
:where [:= :id id]}))
;;; ---------------------------------------------------------------------------
;;; System settings
;;; ---------------------------------------------------------------------------
(defn get-system-setting
"Read a system setting value by key. Returns the string value or nil."
[ds key]
(:value (db/execute-one! ds
{:select [:value]
:from [:system-settings]
:where [:= :key key]})))
(defn set-system-setting!
"Upsert a system setting."
[ds key value]
(db/execute! ds
{:insert-into :system-settings
:values [{:key key :value value :updated-at (instant->timestamp (now-instant))}]
:on-conflict [:key]
:do-update-set {:value value :updated-at (instant->timestamp (now-instant))}}))
;;; ---------------------------------------------------------------------------
;;; Invite helpers
;;; ---------------------------------------------------------------------------
(defn find-invite-by-code
"Look up an invite by code. Returns the invite map or nil."
[ds code]
(db/execute-one! ds
{:select [:i.id :i.community-id :i.code :i.max-uses :i.uses
:i.expires-at :i.created-at
:c.name :c.slug]
:from [[:invites :i]]
:join [[:communities :c] [:= :i.community-id :c.id]]
:where [:= :i.code code]}))
(defn invite-valid?
"Check if an invite is still valid (not expired, not exhausted)."
[invite]
(and invite
;; Not expired
(or (nil? (:expires-at invite))
(not (expired? (:expires-at invite))))
;; Not exhausted
(or (nil? (:max-uses invite))
(< (:uses invite) (:max-uses invite)))))
;;; ---------------------------------------------------------------------------
;;; Cookie helpers
;;; ---------------------------------------------------------------------------
(defn session-cookie
"Build a session cookie map for Ring."
[cookie-name raw-token ttl-days secure?]
{cookie-name {:value raw-token
:path "/"
:max-age (* ttl-days 24 60 60)
:http-only true
:secure secure?
:same-site :lax}})
(defn extract-session-cookie
"Extract the session token from Ring request cookies."
[request cookie-name]
(get-in request [:cookies cookie-name :value]))
+163 -2
View File
@@ -1,5 +1,166 @@
(ns ajet.chat.auth-gw.core
"Auth gateway — http-kit reverse proxy with authn/authz.")
"Auth Gateway — http-kit reverse proxy with authentication.
Single edge entry point for all client traffic. Terminates sessions,
validates tokens, and proxies authenticated requests to internal
services (API, Web SM, TUI SM).
System state held in a single atom for REPL-driven development."
(:refer-clojure :exclude [reset!])
(:require [clojure.tools.logging :as log]
[org.httpkit.server :as http-kit]
[ajet.chat.shared.config :as config]
[ajet.chat.shared.db :as db]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.routes :as routes]
[ajet.chat.auth-gw.rate-limit :as rate-limit]
[ajet.chat.auth-gw.setup :as setup])
(:gen-class))
(defonce system (atom nil))
;;; ---------------------------------------------------------------------------
;;; Provider migration (env config → DB)
;;; ---------------------------------------------------------------------------
(defn- migrate-config-providers-to-db!
"On first startup, if the DB has no OAuth providers but the config file
has enabled providers, auto-migrate them to the DB for backward compat."
[ds oauth-config]
(when (zero? (auth/count-oauth-providers ds))
(doseq [[provider-kw pcfg] oauth-config
:when (:enabled pcfg)]
(let [ptype (name provider-kw)]
(log/info "Auto-migrating OAuth provider from config to DB:" ptype)
(auth/insert-oauth-provider! ds
(cond-> {:provider-type ptype
:display-name (case provider-kw
:github "GitHub"
:gitea "Gitea"
:oidc "SSO"
ptype)
:slug ptype
:client-id (:client-id pcfg)
:client-secret (:client-secret pcfg)}
(:base-url pcfg) (assoc :base-url (:base-url pcfg))
(:issuer-url pcfg) (assoc :issuer-url (:issuer-url pcfg))))))))
;;; ---------------------------------------------------------------------------
;;; Lifecycle
;;; ---------------------------------------------------------------------------
(defn start!
"Start the Auth Gateway service.
1. Load config (EDN + env vars)
2. Create DB connection pool (HikariCP)
3. Run migrations
4. Initialize OAuth providers atom (from DB)
5. Initialize setup-complete? atom
6. Initialize rate limiter (in-memory atom)
7. Start http-kit server with reitit router
8. Log startup"
[& [{:keys [config-overrides]}]]
(when @system
(log/warn "System already started — call (stop!) first")
(throw (ex-info "System already running" {})))
(let [config (config/load-config {:resource "auth-gw-config.edn"})
config (if config-overrides
(merge config config-overrides)
config)
_ (log/info "Loaded config:" (config/redact config))
;; Database — Auth GW has direct PG access for sessions/tokens
ds (db/make-datasource (:db config))
_ (log/info "Database connection pool created")
_ (when (get-in config [:db :migrations :enabled] true)
(db/migrate! ds (get-in config [:db :migrations])))
;; Auto-migrate OAuth providers from config to DB (backward compat)
_ (migrate-config-providers-to-db! ds (:oauth config))
;; OAuth providers — loaded from DB, cached in atom
oauth-provs (atom (auth/list-oauth-providers ds))
_ (log/info "Loaded" (count @oauth-provs) "OAuth providers from DB")
;; Setup completion status — cached in atom
setup-atom (atom nil)
;; Rate limiter
limiter (rate-limit/make-limiter)
cleanup-fn (rate-limit/start-cleanup-task! limiter)
_ (log/info "Rate limiter initialized")
;; System map
sys {:config config
:ds ds
:limiter limiter
:oauth-providers-atom oauth-provs
:setup-complete-atom setup-atom}
;; HTTP server
handler (routes/app sys)
port (get-in config [:server :port] 3000)
host (get-in config [:server :host] "0.0.0.0")
server (http-kit/run-server handler
{:port port
:ip host
:max-body (* 12 1024 1024)
;; Don't buffer SSE responses
:server-header "ajet-auth-gw"})]
(clojure.core/reset! system (assoc sys
:server server
:port port
:cleanup-fn cleanup-fn))
(log/info (str "Auth Gateway started on " host ":" port))
@system))
(defn stop!
"Stop the Auth Gateway. Shuts down HTTP server, DB pool in order."
[]
(when-let [sys @system]
(log/info "Shutting down Auth Gateway...")
;; Stop HTTP server (wait up to 30s for in-flight requests)
(when-let [server (:server sys)]
(server :timeout 30000)
(log/info "HTTP server stopped"))
;; Cancel rate limiter cleanup task
(when-let [cleanup-fn (:cleanup-fn sys)]
(future-cancel cleanup-fn)
(log/info "Rate limiter cleanup task stopped"))
;; Close DB pool
(when-let [ds (:ds sys)]
(try
(db/close-datasource ds)
(log/info "Database connection pool closed")
(catch Exception e
(log/error e "Error closing database pool"))))
(clojure.core/reset! system nil)
(log/info "Auth Gateway stopped")))
(defn reset!
"Stop then start the system (REPL convenience)."
[]
(stop!)
(start!))
;;; ---------------------------------------------------------------------------
;;; Entry point
;;; ---------------------------------------------------------------------------
(defn -main [& _args]
(println "ajet-chat auth gateway starting..."))
(start!)
;; Graceful shutdown hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. ^Runnable (fn []
(log/info "Shutdown hook triggered")
(stop!))))
;; Block main thread
@(promise))
@@ -0,0 +1,174 @@
(ns ajet.chat.auth-gw.middleware
"Ring middleware for the Auth Gateway.
Pipeline order (outermost first):
1. wrap-exception-handler — catch-all error handler
2. wrap-cors — CORS headers and OPTIONS preflight
3. wrap-trace-id — ensure X-Trace-Id on every request
4. wrap-rate-limit — token-bucket rate limiting"
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[ajet.chat.auth-gw.rate-limit :as rl]
[ajet.chat.auth-gw.pages :as pages])
(:import [java.io ByteArrayInputStream InputStream]))
;;; ---------------------------------------------------------------------------
;;; Body buffering (for proxy + wrap-params coexistence)
;;; ---------------------------------------------------------------------------
(defn wrap-buffer-body
"Buffer the request body so it can be read by both wrap-params and the proxy.
Without this, wrap-params consumes the InputStream and the proxy gets an empty body."
[handler]
(fn [request]
(let [body (:body request)]
(if (instance? InputStream body)
(let [bytes (.readAllBytes ^InputStream body)
request (assoc request
:body (ByteArrayInputStream. bytes)
:raw-body bytes)]
(handler request))
(handler request)))))
;;; ---------------------------------------------------------------------------
;;; Exception handler
;;; ---------------------------------------------------------------------------
(defn wrap-exception-handler
"Catch-all middleware that turns unhandled exceptions into 500 responses."
[handler]
(fn [request]
(try
(handler request)
(catch Exception e
(let [trace-id (get-in request [:headers "x-trace-id"] "unknown")]
(log/error e "Unhandled exception" {:trace-id trace-id
:uri (:uri request)
:method (:request-method request)})
{:status 500
:headers {"Content-Type" "text/html; charset=utf-8"
"X-Trace-Id" trace-id}
:body (pages/error-page {:status 500
:title "Internal Server Error"
:message "An unexpected error occurred. Please try again later."})})))))
;;; ---------------------------------------------------------------------------
;;; CORS
;;; ---------------------------------------------------------------------------
(defn- origin-allowed?
"Check if the request Origin is in the allowed list.
In dev mode, allow any localhost origin."
[origin allowed-origins dev-mode?]
(cond
(str/blank? origin) false
dev-mode? (or (str/starts-with? origin "http://localhost")
(str/starts-with? origin "http://127.0.0.1")
(contains? (set allowed-origins) origin))
:else (contains? (set allowed-origins) origin)))
(defn- cors-headers
"Build CORS response headers."
[origin config]
(let [methods (or (:allowed-methods config)
[:get :post :put :delete :options])
headers-list (or (:allowed-headers config)
["Content-Type" "Authorization" "X-Trace-Id"])
max-age (or (:max-age config) 86400)]
{"Access-Control-Allow-Origin" origin
"Access-Control-Allow-Methods" (str/join ", " (map #(str/upper-case (name %)) methods))
"Access-Control-Allow-Headers" (str/join ", " headers-list)
"Access-Control-Allow-Credentials" "true"
"Access-Control-Max-Age" (str max-age)}))
(defn wrap-cors
"CORS middleware — adds CORS headers and handles OPTIONS preflight requests."
[handler {:keys [cors] :as config}]
(let [allowed-origins (:allowed-origins cors)
dev-mode? (not (get-in config [:session :cookie-secure] true))]
(fn [request]
(let [origin (get-in request [:headers "origin"])]
(if (and (= :options (:request-method request)) origin)
;; Preflight request — respond immediately
(if (origin-allowed? origin allowed-origins dev-mode?)
{:status 204
:headers (cors-headers origin cors)
:body ""}
{:status 403
:headers {"Content-Type" "text/plain"}
:body "CORS origin not allowed"})
;; Normal request — add CORS headers to response
(let [response (handler request)]
(if (and origin (origin-allowed? origin allowed-origins dev-mode?))
(update response :headers merge (cors-headers origin cors))
response)))))))
;;; ---------------------------------------------------------------------------
;;; Trace ID
;;; ---------------------------------------------------------------------------
(defn wrap-trace-id
"Ensure every request has an X-Trace-Id header. Generates one if missing.
Also adds the trace ID to the response."
[handler]
(fn [request]
(let [existing (get-in request [:headers "x-trace-id"])
trace-id (or existing (str (java.util.UUID/randomUUID)))
request (if existing
request
(assoc-in request [:headers "x-trace-id"] trace-id))
response (handler request)]
(assoc-in response [:headers "X-Trace-Id"] trace-id))))
;;; ---------------------------------------------------------------------------
;;; Rate limiting
;;; ---------------------------------------------------------------------------
(defn wrap-rate-limit
"Apply rate limiting based on route classification.
The limiter-atom is created at startup and shared across all requests.
Rate limit categories are determined by the request URI and method."
[handler limiter-atom rate-limit-config]
(if-not (:enabled rate-limit-config)
handler ;; Rate limiting disabled
(fn [request]
(let [classification (rl/classify-request request)]
(if-not classification
;; No rate limit applies
(handler request)
(let [[category identity-key] classification
result (rl/check-rate-limit! limiter-atom category identity-key)]
(if (:allowed? result)
(handler request)
;; Rate limited
(let [retry-after-s (max 1 (long (Math/ceil (/ (:retry-after-ms result 1000) 1000.0))))]
(log/warn "Rate limited" category identity-key
"retry-after" retry-after-s "s")
{:status 429
:headers {"Content-Type" "text/html; charset=utf-8"
"Retry-After" (str retry-after-s)}
:body (pages/error-page
{:status 429
:title "Too Many Requests"
:message "You're making requests too quickly. Please slow down."
:retry-after retry-after-s})}))))))))
;;; ---------------------------------------------------------------------------
;;; Request logging
;;; ---------------------------------------------------------------------------
(defn wrap-request-logging
"Log each request with method, path, status, and duration."
[handler]
(fn [request]
(let [method (str/upper-case (name (:request-method request)))
path (:uri request)
trace-id (get-in request [:headers "x-trace-id"] "?")
start (System/nanoTime)
response (handler request)
duration (/ (- (System/nanoTime) start) 1e6)]
(log/info (format "[%s] %s %s %d (%.0fms)"
trace-id method path
(:status response 500) duration))
response)))
+520
View File
@@ -0,0 +1,520 @@
(ns ajet.chat.auth-gw.oauth
"OAuth login flows — GitHub, Gitea, and generic OIDC.
Handles the full OAuth2 authorization code flow:
1. Generate authorize URL with state parameter (CSRF protection)
2. Exchange authorization code for access token
3. Fetch user profile from provider
4. Find or create local user + oauth_account
5. Create session and redirect
Providers are loaded dynamically from the database (not static config)."
(:require [babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]
[clojure.tools.logging :as log]
[ring.util.codec :as codec]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.pages :as pages]
[ajet.chat.auth-gw.setup :as setup])
(:import [java.security SecureRandom]
[java.util Base64]))
;;; ---------------------------------------------------------------------------
;;; State parameter (CSRF protection)
;;; ---------------------------------------------------------------------------
(def ^:private secure-random (SecureRandom.))
(defn- generate-state
"Generate a random state parameter for CSRF protection."
[]
(let [buf (byte-array 16)]
(.nextBytes secure-random buf)
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) buf)))
;; In-memory state store with expiry (5 minutes)
(def ^:private state-store (atom {}))
(defn- store-state!
"Store a state parameter with metadata. Returns the state string."
[metadata]
(let [state (generate-state)
expiry (+ (System/currentTimeMillis) (* 5 60 1000))]
;; Clean up expired states while we're at it
(swap! state-store
(fn [store]
(let [now (System/currentTimeMillis)
cleaned (into {} (filter (fn [[_ v]] (> (:expiry v) now))) store)]
(assoc cleaned state (merge metadata {:expiry expiry})))))
state))
(defn- consume-state!
"Validate and consume a state parameter. Returns the metadata or nil."
[state]
(when state
(let [result (atom nil)]
(swap! state-store
(fn [store]
(let [entry (get store state)]
(if (and entry (> (:expiry entry) (System/currentTimeMillis)))
(do (reset! result (dissoc entry :expiry))
(dissoc store state))
(do (reset! result nil)
store)))))
@result)))
;;; ---------------------------------------------------------------------------
;;; GitHub OAuth
;;; ---------------------------------------------------------------------------
(def ^:private github-authorize-url "https://github.com/login/oauth/authorize")
(def ^:private github-token-url "https://github.com/login/oauth/access_token")
(def ^:private github-user-url "https://api.github.com/user")
(def ^:private github-emails-url "https://api.github.com/user/emails")
(defn- github-authorize-redirect
"Build the GitHub OAuth authorize redirect URL."
[client-id state redirect-uri]
(str github-authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"scope" "read:user user:email"
"state" state})))
(defn- github-exchange-code
"Exchange an authorization code for an access token with GitHub."
[client-id client-secret code redirect-uri]
(let [resp (http/post github-token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- github-fetch-profile
"Fetch the user profile from GitHub using the access token."
[access-token]
(let [user-resp (http/get github-user-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})
emails-resp (http/get github-emails-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status user-resp))
(let [user (json/read-str (:body user-resp) :key-fn keyword)
emails (when (= 200 (:status emails-resp))
(json/read-str (:body emails-resp) :key-fn keyword))
primary-email (or (->> emails
(filter :primary)
first
:email)
(:email user))]
{:provider :github
:provider-user-id (str (:id user))
:provider-username (:login user)
:username (:login user)
:display-name (or (:name user) (:login user))
:email (or primary-email "")
:avatar-url (:avatar_url user)}))))
;;; ---------------------------------------------------------------------------
;;; Gitea OAuth
;;; ---------------------------------------------------------------------------
(defn- gitea-authorize-redirect
"Build the Gitea OAuth authorize redirect URL."
[base-url client-id state redirect-uri]
(let [authorize-url (str (str/replace base-url #"/+$" "") "/login/oauth/authorize")]
(str authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"response_type" "code"
"scope" ""
"state" state}))))
(defn- gitea-exchange-code
"Exchange an authorization code for an access token with Gitea."
[base-url client-id client-secret code redirect-uri]
(let [token-url (str (str/replace base-url #"/+$" "") "/login/oauth/access_token")
resp (http/post token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"grant_type" "authorization_code"
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- gitea-fetch-profile
"Fetch the user profile from Gitea using the access token."
[base-url access-token]
(let [user-url (str (str/replace base-url #"/+$" "") "/api/v1/user")
resp (http/get user-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [user (json/read-str (:body resp) :key-fn keyword)]
{:provider :gitea
:provider-user-id (str (:id user))
:provider-username (:login user)
:username (:login user)
:display-name (or (:full_name user) (:login user))
:email (or (:email user) "")
:avatar-url (:avatar_url user)}))))
;;; ---------------------------------------------------------------------------
;;; OIDC (OpenID Connect)
;;; ---------------------------------------------------------------------------
(defn- oidc-discover
"Fetch the OIDC discovery document from the issuer's well-known URL."
[issuer-url]
(let [discovery-url (str (str/replace issuer-url #"/+$" "") "/.well-known/openid-configuration")
resp (http/get discovery-url
{:headers {"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(json/read-str (:body resp) :key-fn keyword))))
(defn- oidc-authorize-redirect
"Build the OIDC authorize redirect URL."
[discovery client-id state redirect-uri]
(let [authorize-url (:authorization_endpoint discovery)]
(str authorize-url "?"
(codec/form-encode {"client_id" client-id
"redirect_uri" redirect-uri
"response_type" "code"
"scope" "openid profile email"
"state" state}))))
(defn- oidc-exchange-code
"Exchange an authorization code for tokens with the OIDC provider."
[discovery client-id client-secret code redirect-uri]
(let [token-url (:token_endpoint discovery)
resp (http/post token-url
{:headers {"Accept" "application/json"
"Content-Type" "application/x-www-form-urlencoded"}
:body (codec/form-encode {"client_id" client-id
"client_secret" client-secret
"code" code
"grant_type" "authorization_code"
"redirect_uri" redirect-uri})
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [body (json/read-str (:body resp) :key-fn keyword)]
(:access_token body)))))
(defn- oidc-fetch-profile
"Fetch the user profile from the OIDC userinfo endpoint."
[discovery access-token]
(let [userinfo-url (:userinfo_endpoint discovery)
resp (http/get userinfo-url
{:headers {"Authorization" (str "Bearer " access-token)
"Accept" "application/json"}
:throw false
:timeout 10000})]
(when (= 200 (:status resp))
(let [user (json/read-str (:body resp) :key-fn keyword)]
{:provider :oidc
:provider-user-id (str (:sub user))
:provider-username (or (:preferred_username user) (:sub user))
:username (or (:preferred_username user)
(:nickname user)
(first (str/split (or (:email user) "user") #"@")))
:display-name (or (:name user)
(:preferred_username user)
(:sub user))
:email (or (:email user) "")
:avatar-url (:picture user)}))))
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn login-page-handler
"Handle GET /auth/login — render the login page.
If setup is not complete, redirect to /setup.
If a `provider` query param is present, redirect to that provider's
authorize URL. Otherwise render the login page with provider buttons."
[{:keys [ds config oauth-providers-atom] :as sys} request]
(let [first-user? (zero? (auth/count-users ds))
setup-done? (setup/setup-complete? sys)
providers @oauth-providers-atom]
;; Redirect to setup wizard when: no users, no providers, setup not done
;; (If providers exist, show login page so admin can create their account via OAuth)
(if (and first-user? (not setup-done?) (empty? providers))
{:status 302
:headers {"Location" "/setup"}
:body ""}
;; Normal login page
(let [providers providers
params (:query-params request)
provider-slug (get params "provider")
error (get params "error")
invite-code (or (get params "invite")
(get-in request [:cookies "ajet_invite" :value]))
base-url (str "http"
(when (get-in config [:session :cookie-secure]) "s")
"://"
(get-in request [:headers "host"]))
invite-info (when invite-code
(let [invite (auth/find-invite-by-code ds invite-code)]
(when (auth/invite-valid? invite)
{:community-name (:name invite)})))]
(if provider-slug
;; Redirect to OAuth provider
(let [provider-row (some #(when (= (:slug %) provider-slug) %) providers)]
(if provider-row
(let [redirect-uri (str base-url "/auth/callback/" provider-slug)
state (store-state! {:provider-slug provider-slug
:invite-code invite-code})
redirect-url
(case (:provider-type provider-row)
"github" (github-authorize-redirect
(:client-id provider-row) state redirect-uri)
"gitea" (gitea-authorize-redirect
(:base-url provider-row)
(:client-id provider-row) state redirect-uri)
"oidc" (let [discovery (oidc-discover (:issuer-url provider-row))]
(if discovery
(oidc-authorize-redirect
discovery (:client-id provider-row) state redirect-uri)
(do (log/error "OIDC discovery failed for" (:issuer-url provider-row))
nil)))
nil)]
(if redirect-url
{:status 302
:headers {"Location" redirect-url}
:body ""}
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Provider configuration error"))}
:body ""}))
;; Unknown provider slug
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Unknown provider"))}
:body ""}))
;; Render login page
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:body (pages/login-page {:providers providers
:error error
:invite-info invite-info
:first-user? first-user?
})})))))
(defn- handle-oauth-success
"Common handler after successful OAuth profile fetch.
Finds or creates the user, creates a session, handles invite flow,
and redirects appropriately."
[ds config sys profile invite-code]
(let [ttl-days (get-in config [:session :ttl-days] 30)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
cookie-secure (get-in config [:session :cookie-secure] true)
;; Find or create user
existing-user (auth/find-user-by-oauth ds (:provider profile) (:provider-user-id profile))
user (or existing-user
(auth/create-user-from-oauth! ds profile))
user-id (:id user)
;; Create session
raw-token (auth/create-session! ds user-id ttl-days)
;; Determine redirect
setup-done? (setup/setup-complete? sys)
first-user? (and (nil? existing-user)
(= 1 (auth/count-users ds)))
redirect-to (cond
;; First-user bootstrap: redirect to setup if not complete
(and first-user? (not setup-done?)) "/setup"
;; Invite flow: accept invite then redirect to community
invite-code (str "/invite/" (codec/url-encode invite-code) "/accept")
;; Normal: redirect to app
:else "/")]
(log/info "OAuth login success for user" user-id
"provider" (name (:provider profile))
(if existing-user "existing" "new") "user"
"redirect-to" redirect-to)
(cond-> {:status 302
:headers {"Location" redirect-to}
:body ""
:cookies (auth/session-cookie cookie-name raw-token ttl-days cookie-secure)}
;; Clear invite cookie after use
invite-code
(update :cookies assoc "ajet_invite" {:value ""
:path "/"
:max-age 0}))))
(defn callback-handler
"Handle GET /auth/callback/:provider — OAuth callback.
Validates the state parameter, exchanges the code for an access token,
fetches the user profile, and completes the login flow.
Looks up the provider by slug from the DB-backed atom."
[{:keys [ds config oauth-providers-atom] :as sys} request]
(let [provider-slug (get-in request [:path-params :provider])
params (:query-params request)
code (get params "code")
state (get params "state")
error-param (get params "error")
;; Look up provider from DB-backed atom
provider-row (some #(when (= (:slug %) provider-slug) %) @oauth-providers-atom)
base-url (str "http"
(when (get-in config [:session :cookie-secure]) "s")
"://"
(get-in request [:headers "host"]))
redirect-uri (str base-url "/auth/callback/" provider-slug)]
(cond
;; Provider returned an error
error-param
(do (log/warn "OAuth error from provider" provider-slug ":" error-param)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode (str "Login failed: " error-param)))}
:body ""})
;; Missing code or state
(or (str/blank? code) (str/blank? state))
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Invalid OAuth callback — missing parameters"))}
:body ""}
;; Unknown provider
(nil? provider-row)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Unknown OAuth provider"))}
:body ""}
;; Invalid or expired state (CSRF check)
:else
(let [state-meta (consume-state! state)]
(if-not state-meta
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Invalid or expired login session. Please try again."))}
:body ""}
;; Exchange code and fetch profile
(let [invite-code (:invite-code state-meta)
ptype (:provider-type provider-row)]
(try
(let [[access-token profile]
(case ptype
"github"
(let [token (github-exchange-code
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri)]
[token (when token (github-fetch-profile token))])
"gitea"
(let [;; Server-side URL may differ from browser-facing base-url
;; (e.g. Docker host.docker.internal vs localhost)
server-url (or (get-in config [:oauth :gitea-server-base-url])
(:base-url provider-row))
token (gitea-exchange-code
server-url
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri)]
[token (when token (gitea-fetch-profile server-url token))])
"oidc"
(let [discovery (oidc-discover (:issuer-url provider-row))
token (when discovery
(oidc-exchange-code
discovery
(:client-id provider-row)
(:client-secret provider-row)
code redirect-uri))]
[token (when token (oidc-fetch-profile discovery token))])
;; Unknown provider type
[nil nil])]
(if profile
(handle-oauth-success ds config sys profile invite-code)
(do (log/warn "OAuth flow failed — could not obtain profile from" provider-slug)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Could not authenticate with provider. Please try again."))}
:body ""})))
(catch Exception e
(log/error e "OAuth callback error for provider" provider-slug)
{:status 302
:headers {"Location" (str "/auth/login?error="
(codec/url-encode "Authentication service error. Please try again."))}
:body ""}))))))))
(defn logout-handler
"Handle POST /auth/logout — destroy session and clear cookie."
[{:keys [ds config]} request]
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")
cookie-secure (get-in config [:session :cookie-secure] true)
cookie-value (auth/extract-session-cookie request cookie-name)
cookie-result (auth/destroy-session! ds cookie-value cookie-name cookie-secure)]
(log/info "User logged out")
(merge {:status 302
:headers {"Location" "/auth/login"}
:body ""}
cookie-result)))
(defn invite-landing-handler
"Handle GET /invite/:code — render invite landing page or error."
[{:keys [ds config]} request]
(let [code (get-in request [:path-params :code])
invite (auth/find-invite-by-code ds code)]
(cond
;; Invite not found
(nil? invite)
{:status 404
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/invite-page {:community-name "Unknown"
:invite-code code
:error "This invite link is invalid or has been revoked."})}
;; Invite expired or exhausted
(not (auth/invite-valid? invite))
{:status 410
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/invite-page {:community-name (:name invite)
:invite-code code
:error "This invite has expired or reached its maximum uses."})}
;; Valid invite — show landing page, set invite cookie
:else
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:cookies {"ajet_invite" {:value code
:path "/"
:max-age (* 30 60) ;; 30 minutes
:http-only true
:same-site :lax}}
:body (pages/invite-page {:community-name (:name invite)
:invite-code code})})))
+321
View File
@@ -0,0 +1,321 @@
(ns ajet.chat.auth-gw.pages
"HTML pages rendered by the Auth Gateway using Hiccup.
Renders login, error, invite landing, and setup wizard pages.
Uses Tailwind CSS via CDN for styling."
(:require [hiccup2.core :as h]
[hiccup.util :as hu]))
;;; ---------------------------------------------------------------------------
;;; Layout
;;; ---------------------------------------------------------------------------
(defn- page-shell
"Base HTML shell with Tailwind CDN and dark theme."
[title & body]
(str
"<!DOCTYPE html>"
(h/html
[:html {:lang "en" :class "dark"}
[:head
[:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
[:title (str title " - ajet chat")]
[:script {:src "https://cdn.tailwindcss.com"}]
[:script
(h/raw "tailwindcss.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81' }
}
}
}
}")]]
[:body {:class "bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center antialiased"}
[:div {:class "w-full max-w-md mx-auto px-4"}
body]]])))
(defn- provider-button
"Render an OAuth provider login button."
[slug label icon-svg]
[:a {:href (str "/auth/login?provider=" slug)
:class "flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 hover:border-gray-600 text-gray-100 font-medium transition-colors duration-150 no-underline"}
(h/raw icon-svg)
[:span (str "Continue with " label)]])
(def ^:private provider-icons
{"github" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/></svg>"
"gitea" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 6.407.140 7.954.140 7.954c.224.594.47.972.736 1.249-.192.294-.39.633-.543 1.049-.265.723-.264 1.528.007 2.238.271.71.755 1.295 1.379 1.695-.177.556-.193 1.173-.044 1.742.198.755.672 1.39 1.311 1.773.16.096.331.177.509.244-.077.394-.07.808.022 1.207.135.585.426 1.112.834 1.525.407.413.928.705 1.506.844.354.086.714.124 1.074.112a3.77 3.77 0 0 0 1.18-.262c.387.35.851.593 1.35.715.499.122 1.02.107 1.512-.044.397-.122.765-.327 1.073-.602.195.06.396.098.601.112.609.042 1.218-.109 1.728-.43.51-.32.905-.78 1.132-1.325a3.09 3.09 0 0 0 .226-1.195c.503-.21.95-.533 1.302-.946.352-.413.597-.907.711-1.433.115-.526.095-1.072-.057-1.587a3.19 3.19 0 0 0-.794-1.303 3.17 3.17 0 0 0 .382-1.665 3.19 3.19 0 0 0-.677-1.764 3.23 3.23 0 0 0-1.458-1.043 3.25 3.25 0 0 0-1.8-.138c-.39-.535-.903-.95-1.487-1.21a3.36 3.36 0 0 0-1.818-.279c-.594.073-1.154.318-1.618.696a3.22 3.22 0 0 0-1.028-.63 3.29 3.29 0 0 0-1.202-.168 3.27 3.27 0 0 0-1.558.519 3.24 3.24 0 0 0-1.074 1.263c-.203-.064-.412-.1-.622-.112zM12 6.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11z\"/></svg>"
"oidc" "<svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z\"/></svg>"})
(defn- icon-for-provider-type [provider-type]
(get provider-icons provider-type (get provider-icons "oidc")))
;;; ---------------------------------------------------------------------------
;;; Common UI components
;;; ---------------------------------------------------------------------------
(defn- error-banner [error]
(when error
[:div {:class "rounded-lg bg-red-900/40 border border-red-800 px-4 py-3 text-red-300 text-sm"}
error]))
(defn- text-input [{:keys [name type placeholder value required autocomplete]}]
[:input {:type (or type "text") :name name :required required
:placeholder placeholder :value (or value "")
:autocomplete (or autocomplete "off")
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-brand-500"}])
(defn- submit-button [label]
[:button {:type "submit"
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors"}
label])
(defn- step-indicator
"Render a step progress indicator. current is 1-based."
[current total]
[:div {:class "flex items-center justify-center gap-2 mb-6"}
(for [i (range 1 (inc total))]
[:div {:class (str "w-2.5 h-2.5 rounded-full "
(if (= i current)
"bg-brand-500"
(if (< i current)
"bg-brand-700"
"bg-gray-700")))}])])
;;; ---------------------------------------------------------------------------
;;; Login Page
;;; ---------------------------------------------------------------------------
(defn login-page
"Render the login page with dynamic OAuth provider buttons and optional password form.
opts:
:providers - vector of provider maps from DB [{:slug :display-name :provider-type ...}]
:error - optional error message string
:invite-info - optional map {:community-name \"...\"} for invite flow
:first-user? - true if no users exist yet (bootstrap mode)"
[{:keys [providers error invite-info first-user?]}]
(page-shell "Sign In"
[:div {:class "text-center space-y-8 py-12"}
;; Logo / branding
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"}
(if first-user?
"Welcome! Sign in to set up your community."
"Sign in to continue")]]
;; Error message
(error-banner error)
;; OAuth provider buttons (dynamic from DB)
(when (seq providers)
[:div {:class "space-y-3"}
(for [{:keys [slug display-name provider-type]} providers]
(provider-button slug display-name (icon-for-provider-type provider-type)))])
;; Invite info
(when invite-info
[:div {:class "border-t border-gray-800 pt-4 space-y-2"}
[:p {:class "text-gray-500 text-xs uppercase tracking-wider"} "Accepting invite"]
[:p {:class "text-gray-300 font-medium"}
(str "Joining: " (:community-name invite-info))]])
;; First user hint
(when first-user?
[:div {:class "border-t border-gray-800 pt-4"}
[:p {:class "text-gray-500 text-sm"}
"You'll be the first user and community owner."]])]))
;;; ---------------------------------------------------------------------------
;;; Error Page
;;; ---------------------------------------------------------------------------
(defn error-page
"Render a generic error page.
opts:
:status - HTTP status code (401, 403, 404, 429, 502, 503)
:title - error title
:message - error description
:retry-after - optional seconds for 429 responses"
[{:keys [status title message retry-after]}]
(let [status-text (case status
401 "Unauthorized"
403 "Forbidden"
404 "Not Found"
429 "Too Many Requests"
502 "Bad Gateway"
503 "Service Unavailable"
"Error")
display-title (or title status-text)]
(page-shell display-title
[:div {:class "text-center space-y-6 py-16"}
[:div {:class "space-y-2"}
[:p {:class "text-6xl font-bold text-gray-700"} (str status)]
[:h1 {:class "text-xl font-semibold text-gray-300"} display-title]]
[:p {:class "text-gray-500 text-sm max-w-sm mx-auto"}
(or message "Something went wrong. Please try again.")]
(when retry-after
[:div {:class "space-y-2"}
[:p {:class "text-gray-500 text-sm"}
(str "Please wait " retry-after " seconds before retrying.")]
[:div {:class "w-full bg-gray-800 rounded-full h-1.5"}
[:div {:class "bg-brand-500 h-1.5 rounded-full transition-all duration-1000"
:style "width: 0%"
:id "retry-bar"}]]
[:script
(h/raw (format "(() => { let s=%d, el=document.getElementById('retry-bar'), iv=setInterval(() => { s--; el.style.width=(((%d-s)/%d)*100)+'%%'; if(s<=0){clearInterval(iv);location.reload();}},1000);})()"
retry-after retry-after retry-after))]])
[:div {:class "pt-4 space-x-4"}
[:a {:href "/"
:class "inline-block px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors no-underline"}
"Go Home"]
(when (#{401 403} status)
[:a {:href "/auth/login"
:class "inline-block px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 text-sm font-medium transition-colors no-underline"}
"Sign In"])]])))
;;; ---------------------------------------------------------------------------
;;; Invite Landing Page
;;; ---------------------------------------------------------------------------
(defn invite-page
"Render the invite landing page.
opts:
:community-name - name of the community being joined
:invite-code - the invite code
:error - optional error message"
[{:keys [community-name invite-code error]}]
(page-shell "Join Community"
[:div {:class "text-center space-y-8 py-12"}
;; Logo
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]]
;; Error
(error-banner error)
;; Invite info
[:div {:class "space-y-4"}
[:div {:class "rounded-lg bg-gray-900 border border-gray-800 p-6 space-y-3"}
[:p {:class "text-gray-400 text-sm"} "You've been invited to join"]
[:p {:class "text-2xl font-bold text-gray-100"} community-name]]
[:a {:href (str "/auth/login?invite=" (hu/url-encode invite-code))
:class "inline-block w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors text-center no-underline"}
"Accept Invite & Sign In"]]]))
;;; ---------------------------------------------------------------------------
;;; Setup Wizard Pages
;;; ---------------------------------------------------------------------------
(defn setup-configure-providers-page
"Render the OAuth provider configuration form (wizard step 2).
Shows existing providers with delete buttons and a form to add new ones.
Providers require: type, display name, slug, client ID/secret, and
type-specific URLs (base-url for Gitea, issuer-url for OIDC).
opts:
:providers - vector of existing provider rows from DB
:error - optional validation error message"
[{:keys [providers error]}]
(page-shell "Setup - OAuth Providers"
[:div {:class "space-y-8 py-12"}
[:div {:class "text-center space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"} "Configure at least one OAuth login provider to get started."]]
(step-indicator 1 2)
(error-banner error)
;; Existing providers
(when (seq providers)
[:div {:class "space-y-2"}
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Configured Providers"]
(for [{:keys [id display-name provider-type slug]} providers]
[:div {:class "flex items-center justify-between bg-gray-900 border border-gray-800 rounded-lg p-3"}
[:div {:class "flex items-center gap-3"}
[:span {:class "text-xs font-mono text-gray-500 uppercase"} provider-type]
[:span {:class "text-gray-100 font-medium"} display-name]
[:span {:class "text-gray-500 text-xs"} (str "(" slug ")")]]
[:form {:method "post" :action (str "/setup/providers/" id "/delete")}
[:button {:type "submit"
:class "text-red-400 hover:text-red-300 text-sm px-2 py-1 rounded hover:bg-red-900/30 transition-colors"}
"Remove"]]])])
;; Add provider form
[:div {:class "space-y-3 text-left"}
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Add Provider"]
[:form {:method "post" :action "/setup/providers" :class "space-y-3"}
[:div
[:label {:class "block text-sm text-gray-400 mb-1"} "Provider Type"]
[:select {:name "provider-type"
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 focus:outline-none focus:border-brand-500"}
[:option {:value "github"} "GitHub"]
[:option {:value "gitea"} "Gitea"]
[:option {:value "oidc"} "OIDC (OpenID Connect)"]]]
[:label {:class "block text-sm text-gray-400"} "Display Name"]
(text-input {:name "display-name" :placeholder "e.g. GitHub"})
[:label {:class "block text-sm text-gray-400"} "Slug (URL-safe identifier)"]
(text-input {:name "slug" :placeholder "e.g. github"})
[:label {:class "block text-sm text-gray-400"} "Client ID"]
(text-input {:name "client-id" :placeholder "OAuth client ID"})
[:label {:class "block text-sm text-gray-400"} "Client Secret"]
(text-input {:name "client-secret" :type "password" :placeholder "OAuth client secret"})
[:label {:class "block text-sm text-gray-400"} "Base URL (Gitea only)"]
(text-input {:name "base-url" :placeholder "e.g. https://gitea.example.com"})
[:label {:class "block text-sm text-gray-400"} "Issuer URL (OIDC only)"]
(text-input {:name "issuer-url" :placeholder "e.g. https://auth.example.com"})
(submit-button "Add Provider")]]
;; Navigation — require at least one provider before proceeding
(when (seq providers)
[:div {:class "pt-4"}
[:a {:href "/auth/login"
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors no-underline"}
"Continue — Sign in with your provider"]])]))
(defn setup-create-community-page
"Render the community creation form (wizard step 3).
Collects community name and slug. Slug must be lowercase alphanumeric
with hyphens (min 2 chars). On success, completes the setup wizard.
opts:
:error - optional validation/API error message
:name - prefill community name on re-render after error
:slug - prefill community slug on re-render after error"
[{:keys [error name slug]}]
(page-shell "Setup - Create Community"
[:div {:class "text-center space-y-8 py-12"}
[:div {:class "space-y-2"}
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
[:p {:class "text-gray-400 text-sm"} "Create your first community."]]
(step-indicator 2 2)
(error-banner error)
[:form {:method "post" :action "/setup/create-community" :class "space-y-3 text-left"}
[:label {:class "block text-sm text-gray-400"} "Community Name"]
(text-input {:name "name" :placeholder "My Team" :value name :required true})
[:label {:class "block text-sm text-gray-400 pt-2"} "Slug"]
(text-input {:name "slug" :placeholder "my-team" :value slug :required true})
[:p {:class "text-gray-500 text-xs"} "Lowercase letters, digits, and hyphens only."]
[:div {:class "pt-2"}
(submit-button "Create Community & Finish Setup")]]]))
+258
View File
@@ -0,0 +1,258 @@
(ns ajet.chat.auth-gw.proxy
"Reverse proxy — forwards authenticated requests to internal services.
Uses babashka.http-client for synchronous proxying and http-kit's
async channel for SSE pass-through streaming."
(:require [babashka.http-client :as http]
[clojure.string :as str]
[clojure.tools.logging :as log]
[org.httpkit.server :as hk])
(:import [java.io InputStream]))
;;; ---------------------------------------------------------------------------
;;; Service resolution
;;; ---------------------------------------------------------------------------
(defn- service-url
"Build the base URL for a service from config."
[{:keys [host port] :or {host "localhost"}}]
(str "http://" host ":" port))
(defn resolve-target
"Determine the target service and rewritten path for a request URI.
Returns [service-key path] or nil if no match.
Service routing:
- /app/* /sse/* /web/* / -> :web-sm
- /api/* -> :api
- /tui/* /tui/sse/* -> :tui-sm"
[uri]
(cond
;; Web SM routes
(= uri "/")
[:web-sm "/"]
(= uri "/setup")
[:web-sm "/setup"]
(= uri "/app")
[:web-sm "/app"]
(str/starts-with? uri "/app/")
[:web-sm uri]
(str/starts-with? uri "/sse/")
[:web-sm uri]
(str/starts-with? uri "/web/")
[:web-sm uri]
;; API routes
(str/starts-with? uri "/api/")
[:api uri]
;; TUI SM routes
(str/starts-with? uri "/tui/")
[:tui-sm uri]
:else nil))
(defn- build-target-url
"Build the full target URL from service config and request."
[services service-key path query-string]
(let [base (service-url (get services service-key))]
(if (str/blank? query-string)
(str base path)
(str base path "?" query-string))))
;;; ---------------------------------------------------------------------------
;;; Header manipulation
;;; ---------------------------------------------------------------------------
(def ^:private stripped-request-headers
"Headers to strip from proxied requests (prevent spoofing)."
#{"x-user-id" "x-user-role" "x-community-id" "x-trace-id"
"x-forwarded-for" "x-forwarded-proto" "x-forwarded-host"
"host" "connection" "keep-alive" "transfer-encoding"
"te" "trailer" "upgrade" "content-length"})
(defn- clean-request-headers
"Strip auth/hop-by-hop headers and normalize values for java.net.http."
[headers]
(into {}
(comp (remove (fn [[k _]] (contains? stripped-request-headers (str/lower-case k))))
(map (fn [[k v]] [k (str/replace (str v) #"\r?\n" ", ")])))
headers))
(defn- inject-proxy-headers
"Add auth and tracing headers for the upstream service."
[headers {:keys [user-id user-role community-id trace-id remote-addr]}]
(cond-> headers
user-id (assoc "X-User-Id" user-id)
user-role (assoc "X-User-Role" user-role)
community-id (assoc "X-Community-Id" community-id)
trace-id (assoc "X-Trace-Id" trace-id)
remote-addr (assoc "X-Forwarded-For" remote-addr)))
(defn- is-sse-request?
"Check if the request is for an SSE endpoint."
[uri]
(or (str/starts-with? uri "/sse/")
(str/starts-with? uri "/tui/sse/")))
;;; ---------------------------------------------------------------------------
;;; Synchronous proxy (non-SSE)
;;; ---------------------------------------------------------------------------
(def ^:private proxy-client
"HTTP client that never follows redirects — upstream redirects must be
passed through to the client untouched."
(http/client {:follow-redirects :never}))
(defn- proxy-sync
"Forward a non-SSE request synchronously via babashka.http-client."
[target-url method headers body]
(try
(let [opts (cond-> {:uri target-url
:method method
:headers headers
:throw false
:timeout 30000
:client proxy-client}
body (assoc :body body))
resp (http/request opts)]
{:status (:status resp)
:headers (-> (:headers resp)
(dissoc "transfer-encoding" "connection")
;; Ensure headers are string->string
(->> (into {}
(map (fn [[k v]]
[(name k)
(if (sequential? v) (str/join ", " v) (str v))])))))
:body (:body resp)})
(catch java.io.IOException e
;; Java 21's HttpClient rejects 204/304 responses that include
;; a Content-Length header (strict HTTP compliance). Detect and
;; return the correct no-content status directly.
(let [msg (str (.getMessage e))]
(if-let [[_ status] (re-find #"content length header with (\d+) response" msg)]
{:status (Integer/parseInt status)
:headers {}
:body nil}
(do (log/error e "Proxy IO error to" target-url)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"}))))
(catch Exception e
(log/error e "Proxy error to" target-url)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"})))
;;; ---------------------------------------------------------------------------
;;; SSE streaming proxy
;;; ---------------------------------------------------------------------------
(defn- proxy-sse
"Forward an SSE request using http-kit async channel for streaming.
Opens an http-kit async response, makes a streaming request to the
upstream, and pipes chunks through without buffering."
[target-url headers hk-channel]
(try
(let [resp (http/get target-url
{:headers (assoc headers "Accept" "text/event-stream")
:throw false
:as :stream})]
(if (= 200 (:status resp))
(do
;; Send initial response headers
(hk/send! hk-channel
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store"
"Connection" "keep-alive"
"X-Accel-Buffering" "no"}}
false)
;; Stream body in a background thread
(future
(try
(let [^InputStream is (:body resp)
buf (byte-array 4096)]
(loop []
(let [n (.read is buf)]
(when (and (pos? n) (hk/open? hk-channel))
(hk/send! hk-channel
(String. buf 0 n "UTF-8")
false)
(recur))))
;; Upstream closed
(hk/close hk-channel))
(catch Exception e
(when-not (instance? java.io.IOException e)
(log/warn e "SSE stream error"))
(hk/close hk-channel)))))
;; Upstream error
(do
(hk/send! hk-channel
{:status (:status resp)
:headers {"Content-Type" "text/plain"}
:body "Upstream SSE connection failed"}
true))))
(catch Exception e
(log/error e "SSE proxy connection error to" target-url)
(hk/send! hk-channel
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — upstream service unavailable"}
true))))
;;; ---------------------------------------------------------------------------
;;; Public API
;;; ---------------------------------------------------------------------------
(defn proxy-request
"Proxy a Ring request to the appropriate internal service.
services: map from config {:api {:host .. :port ..} :web-sm ... :tui-sm ...}
auth-info: map of {:user-id :user-role :community-id :trace-id :remote-addr}
request: Ring request map
Returns a Ring response map (or uses http-kit async for SSE)."
[services auth-info request]
(let [uri (:uri request)
resolution (resolve-target uri)]
(if-not resolution
{:status 404
:headers {"Content-Type" "text/plain"}
:body "Not Found"}
(let [[service-key path] resolution
service-cfg (get services service-key)]
(if-not service-cfg
(do (log/error "No service config for" service-key)
{:status 502
:headers {"Content-Type" "text/plain"}
:body "Bad Gateway — service not configured"})
(let [target-url (build-target-url services service-key path (:query-string request))
headers (-> (:headers request)
clean-request-headers
(inject-proxy-headers auth-info))]
(if (is-sse-request? uri)
;; SSE: use http-kit async channel
(hk/with-channel request hk-channel
(hk/on-close hk-channel
(fn [_status]
(log/debug "SSE client disconnected from" uri)))
(proxy-sse target-url headers hk-channel))
;; Non-SSE: synchronous proxy
;; Use :raw-body (buffered bytes) if available, otherwise :body
;; :raw-body is set by wrap-buffer-body so the proxy has the
;; original body even after wrap-params consumed the InputStream
(let [body (or (when-let [raw (:raw-body request)]
(when (pos? (alength raw)) raw))
(:body request))]
(proxy-sync target-url
(:request-method request)
headers
body)))))))))
@@ -0,0 +1,183 @@
(ns ajet.chat.auth-gw.rate-limit
"In-memory token bucket rate limiter.
Each bucket is keyed by [category identity-key] and tracks remaining tokens
plus the last refill timestamp. Expired/stale buckets are cleaned up
periodically by a background thread."
(:require [clojure.tools.logging :as log])
(:import [java.time Instant Duration]))
;;; ---------------------------------------------------------------------------
;;; Configuration
;;; ---------------------------------------------------------------------------
(def ^:private rate-rules
"Rate limit rules keyed by category keyword.
:capacity — max tokens (requests) in the window
:refill-ms — window size in milliseconds"
{:auth-callback {:capacity 10 :refill-ms 60000} ; 10/min per IP
:api-write {:capacity 60 :refill-ms 60000} ; 60/min per user
:api-read {:capacity 120 :refill-ms 60000} ; 120/min per user
:webhook {:capacity 30 :refill-ms 60000} ; 30/min per webhook
:sse {:capacity 5 :refill-ms 60000}}) ; 5/min per user
(def ^:private stale-threshold-ms
"Remove buckets that haven't been touched in 10 minutes."
600000)
;;; ---------------------------------------------------------------------------
;;; Bucket state
;;; ---------------------------------------------------------------------------
(defn make-limiter
"Create a new rate limiter state atom. Returns the atom."
[]
(atom {}))
(defn- now-ms []
(System/currentTimeMillis))
(defn- refill-bucket
"Refill tokens based on elapsed time since last refill."
[{:keys [tokens last-refill-ms] :as bucket} {:keys [capacity refill-ms]}]
(let [elapsed (- (now-ms) last-refill-ms)]
(if (>= elapsed refill-ms)
;; Full window elapsed — reset to capacity
(assoc bucket
:tokens capacity
:last-refill-ms (now-ms))
;; Partial refill — add proportional tokens
(let [fraction (/ (double elapsed) (double refill-ms))
added (* fraction capacity)
new-tokens (min capacity (+ tokens added))]
(assoc bucket
:tokens new-tokens
:last-refill-ms (now-ms))))))
(defn- get-or-create-bucket
"Get an existing bucket or create a fresh one at capacity."
[buckets bucket-key rule]
(or (get buckets bucket-key)
{:tokens (:capacity rule)
:last-refill-ms (now-ms)}))
(defn check-rate-limit!
"Check and consume a token from the bucket for [category identity-key].
Returns {:allowed? true} if the request is permitted, or
{:allowed? false :retry-after-ms N} if rate limited.
category: one of :auth-callback :api-write :api-read :webhook :sse
identity-key: string (IP address, user-id, or webhook-id)"
[limiter-atom category identity-key]
(let [rule (get rate-rules category)]
(if-not rule
;; Unknown category — allow (no rule defined)
{:allowed? true}
(let [bucket-key [category identity-key]
result (atom nil)]
(swap! limiter-atom
(fn [buckets]
(let [bucket (get-or-create-bucket buckets bucket-key rule)
refilled (refill-bucket bucket rule)
tokens (:tokens refilled)]
(if (>= tokens 1.0)
;; Allow — consume a token
(do (reset! result {:allowed? true})
(assoc buckets bucket-key
(assoc refilled
:tokens (dec tokens)
:last-access-ms (now-ms))))
;; Deny — calculate retry-after
(let [deficit (- 1.0 tokens)
refill-rate (/ (double (:capacity rule))
(double (:refill-ms rule)))
retry-after (long (Math/ceil (/ deficit refill-rate)))]
(reset! result {:allowed? false
:retry-after-ms retry-after})
(assoc buckets bucket-key
(assoc refilled :last-access-ms (now-ms))))))))
@result))))
;;; ---------------------------------------------------------------------------
;;; Cleanup
;;; ---------------------------------------------------------------------------
(defn cleanup-stale!
"Remove buckets that haven't been accessed in over `stale-threshold-ms`."
[limiter-atom]
(let [cutoff (- (now-ms) stale-threshold-ms)
removed (atom 0)]
(swap! limiter-atom
(fn [buckets]
(let [active (into {}
(filter (fn [[_ b]]
(> (get b :last-access-ms 0) cutoff)))
buckets)]
(reset! removed (- (count buckets) (count active)))
active)))
(when (pos? @removed)
(log/debug "Rate limiter cleanup: removed" @removed "stale buckets"))))
(defn start-cleanup-task!
"Start a background thread that periodically cleans up stale buckets.
Returns the future (cancel with `future-cancel`)."
[limiter-atom]
(future
(log/info "Rate limiter cleanup task started")
(try
(loop []
(Thread/sleep 60000) ;; every minute
(cleanup-stale! limiter-atom)
(recur))
(catch InterruptedException _
(log/info "Rate limiter cleanup task stopped"))
(catch Exception e
(log/error e "Rate limiter cleanup task error")))))
;;; ---------------------------------------------------------------------------
;;; Route classification
;;; ---------------------------------------------------------------------------
(defn classify-request
"Determine the rate limit category and identity key for a request.
Returns [category identity-key] or nil if no rate limit applies.
request: Ring request map (expects :uri, :request-method, and optionally
::user-id, ::remote-addr, ::webhook-id keys set by auth middleware)."
[request]
(let [uri (:uri request)
method (:request-method request)]
(cond
;; OAuth callback — rate limit by IP
(and (= method :post)
(re-matches #"/auth/callback/.*" uri))
[:auth-callback (or (::remote-addr request)
(get-in request [:headers "x-forwarded-for"])
(:remote-addr request)
"unknown")]
;; Webhook incoming — rate limit by webhook ID
(and (= method :post)
(re-matches #"/api/webhooks/.*/incoming" uri))
[:webhook (or (::webhook-id request) "unknown")]
;; SSE connections — rate limit by user
(and (= method :get)
(or (re-matches #"/sse/.*" uri)
(re-matches #"/tui/sse/.*" uri)))
[:sse (or (::user-id request) "anonymous")]
;; API writes — rate limit by user
(and (= method :post)
(re-matches #"/api/.*" uri))
[:api-write (or (::user-id request) "anonymous")]
;; API reads — rate limit by user
(and (= method :get)
(re-matches #"/api/.*" uri))
[:api-read (or (::user-id request) "anonymous")]
;; No rate limit for other routes
:else nil)))
+307
View File
@@ -0,0 +1,307 @@
(ns ajet.chat.auth-gw.routes
"Reitit router for the Auth Gateway.
Routes are divided into:
- Self-handled: auth pages, health check, invite landing, setup wizard
- Proxied: all other routes forwarded to internal services
with session/token validation and header injection"
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[reitit.ring :as ring]
[ring.middleware.cookies :refer [wrap-cookies]]
[ring.middleware.params :refer [wrap-params]]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.oauth :as oauth]
[ajet.chat.auth-gw.proxy :as proxy]
[ajet.chat.auth-gw.pages :as pages]
[ajet.chat.auth-gw.middleware :as mw]
[ajet.chat.auth-gw.rate-limit :as rl]
[ajet.chat.auth-gw.setup :as setup]))
;;; ---------------------------------------------------------------------------
;;; Auth helpers
;;; ---------------------------------------------------------------------------
(defn- extract-remote-addr
"Get the client's IP address from the request."
[request]
(or (get-in request [:headers "x-forwarded-for"])
(:remote-addr request)
"unknown"))
(defn- session-auth
"Validate session cookie. Returns auth-info map or nil."
[ds request cookie-name]
(let [token (auth/extract-session-cookie request cookie-name)]
(auth/validate-session ds token)))
(defn- api-token-auth
"Validate Bearer token from Authorization header. Returns auth-info map or nil."
[ds request]
(let [auth-header (get-in request [:headers "authorization"])]
(auth/validate-api-token ds auth-header)))
(defn- webhook-auth
"Validate webhook Bearer token. Returns auth-info map or nil."
[ds request]
(let [auth-header (get-in request [:headers "authorization"])]
(auth/validate-webhook-token ds auth-header)))
(defn- web-redirect-to-login
"Redirect to login page (for web browser requests that fail auth)."
[]
{:status 302
:headers {"Location" "/auth/login"}
:body ""})
(defn- json-401
"Return a 401 JSON response (for API/TUI requests that fail auth)."
[trace-id]
{:status 401
:headers {"Content-Type" "application/json"
"X-Trace-Id" (or trace-id "")}
:body "{\"error\":\"unauthorized\",\"message\":\"Invalid or missing authentication\"}"})
(defn- is-web-route?
"Check if the URI is a web-browser route (should redirect on auth failure)."
[uri]
(or (= uri "/")
(= uri "/app")
(= uri "/setup")
(str/starts-with? uri "/app/")
(str/starts-with? uri "/sse/")
(str/starts-with? uri "/web/")))
;;; ---------------------------------------------------------------------------
;;; Proxy handlers with auth
;;; ---------------------------------------------------------------------------
(defn- make-session-proxy-handler
"Create a handler that validates session auth and proxies to a service."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [auth-info (session-auth ds request cookie-name)
trace-id (get-in request [:headers "x-trace-id"])]
(if auth-info
(do
;; Extend session TTL asynchronously
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
;; Proxy the request
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
;; Auth failed
(if (is-web-route? (:uri request))
(web-redirect-to-login)
(json-401 trace-id)))))))
(defn- make-api-proxy-handler
"Create a handler that validates session OR API token auth and proxies to API."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [trace-id (get-in request [:headers "x-trace-id"])
;; Try session auth first, then API token
session (session-auth ds request cookie-name)
api-token (when-not session (api-token-auth ds request))
auth-info (or session api-token)]
(if auth-info
(do
;; Extend session TTL if session auth was used
(when (and session (:session-id session))
(auth/extend-session-ttl! ds (:session-id session) ttl-days))
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
(json-401 trace-id))))))
(defn- make-webhook-proxy-handler
"Create a handler that validates webhook token and proxies to API."
[{:keys [ds config]}]
(let [services (:services config)]
(fn [request]
(let [trace-id (get-in request [:headers "x-trace-id"])
auth-info (webhook-auth ds request)]
(if auth-info
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request)
(json-401 trace-id))))))
(defn- make-tui-proxy-handler
"Create a handler that validates session auth and proxies to TUI SM."
[{:keys [ds config]}]
(let [services (:services config)
cookie-name (get-in config [:session :cookie-name] "ajet_session")
ttl-days (get-in config [:session :ttl-days] 30)]
(fn [request]
(let [auth-info (session-auth ds request cookie-name)
trace-id (get-in request [:headers "x-trace-id"])]
(if auth-info
(do
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
(proxy/proxy-request services
(assoc auth-info
:trace-id trace-id
:remote-addr (extract-remote-addr request))
request))
(json-401 trace-id))))))
;;; ---------------------------------------------------------------------------
;;; Health check
;;; ---------------------------------------------------------------------------
(defn- health-handler
"Health check endpoint. Verifies DB connectivity."
[{:keys [ds]}]
(fn [_request]
(try
(let [result (ajet.chat.shared.db/execute-one! ds
{:select [[[:raw "1"] :ok]]})]
{:status 200
:headers {"Content-Type" "application/json"}
:body "{\"status\":\"ok\",\"db\":\"connected\"}"})
(catch Exception e
(log/warn e "Health check — DB unreachable")
{:status 503
:headers {"Content-Type" "application/json"}
:body "{\"status\":\"degraded\",\"db\":\"disconnected\"}"}))))
;;; ---------------------------------------------------------------------------
;;; Router
;;; ---------------------------------------------------------------------------
(defn app
"Build the full Ring handler with reitit router and middleware stack.
sys: system map {:ds, :config, :limiter, :oauth-providers-atom, :setup-complete-atom}"
[sys]
(let [session-proxy (make-session-proxy-handler sys)
api-proxy (make-api-proxy-handler sys)
webhook-proxy (make-webhook-proxy-handler sys)
tui-proxy (make-tui-proxy-handler sys)
config (:config sys)
limiter (:limiter sys)]
(ring/ring-handler
(ring/router
[;; --- Self-handled: Auth ---
["/auth/login"
{:get {:handler (fn [request]
(oauth/login-page-handler sys request))}}]
["/auth/callback/:provider"
{:get {:handler (fn [request]
(oauth/callback-handler sys request))}}]
["/auth/logout"
{:post {:handler (fn [request]
(oauth/logout-handler sys request))}}]
;; --- Self-handled: Invite ---
["/invite/:code"
{:get {:handler (fn [request]
(oauth/invite-landing-handler sys request))}}]
;; --- Self-handled: Health ---
["/health"
{:get {:handler (health-handler sys)}}]
;; --- Self-handled: Setup Wizard ---
;; Conditional: if setup incomplete, Auth GW renders wizard pages.
;; If setup complete, /setup proxies to Web SM.
["/setup"
{:get {:handler (fn [request]
(if (setup/setup-complete? sys)
(session-proxy request)
(setup/wizard-page-handler sys request)))}}]
["/setup/create-community"
{:get {:handler (fn [request]
(setup/create-community-page-handler sys request))}
:post {:handler (fn [request]
(setup/create-community-handler sys request))}}]
["/setup/providers"
{:post {:handler (fn [request]
(setup/add-provider-handler sys request))}}]
["/setup/providers/:id/delete"
{:post {:handler (fn [request]
(setup/delete-provider-handler sys request))}}]
;; --- Webhook proxy (before /api/* to match first) ---
["/api/webhooks/:webhook-id/incoming"
{:post {:handler webhook-proxy}}]
;; --- API proxy (session or API token) ---
["/api/*"
{:get {:handler api-proxy}
:post {:handler api-proxy}
:put {:handler api-proxy}
:delete {:handler api-proxy}}]
;; --- TUI SM proxy ---
["/tui/sse/*"
{:get {:handler tui-proxy}}]
["/tui/*"
{:post {:handler tui-proxy}}]
;; --- Web SM proxy (SSE) ---
["/sse/*"
{:get {:handler session-proxy}}]
;; --- Web SM proxy (form posts) ---
["/web/*"
{:post {:handler session-proxy}}]
;; --- Web SM proxy (app pages) ---
["/app"
{:get {:handler session-proxy}}]
["/app/*"
{:get {:handler session-proxy}}]
;; --- Web SM proxy (root) ---
["/"
{:get {:handler session-proxy}}]]
;; Router options
{:conflicts nil}) ;; Suppress conflict warnings for catch-all routes
;; Default handler for unmatched routes
(ring/create-default-handler
{:not-found (constantly
{:status 404
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (pages/error-page {:status 404
:title "Not Found"
:message "The page you're looking for doesn't exist."})})})
;; Middleware stack (applied outermost first)
{:middleware [;; Parse cookies
wrap-cookies
;; Buffer body so both wrap-params and proxy can read it
mw/wrap-buffer-body
;; Parse query params and form body
wrap-params
;; Catch-all exception handler (outermost)
mw/wrap-exception-handler
;; CORS
[mw/wrap-cors config]
;; Trace ID generation
mw/wrap-trace-id
;; Request logging
mw/wrap-request-logging
;; Rate limiting
[mw/wrap-rate-limit limiter (get config :rate-limit {:enabled true})]]})))
+234
View File
@@ -0,0 +1,234 @@
(ns ajet.chat.auth-gw.setup
"Admin setup wizard — multi-step first-deployment bootstrap.
Flow:
1. Configure OAuth providers (no auth needed — no users exist yet)
2. Admin logs in via one of the configured providers (normal OAuth flow)
3. Create first community (auth required — admin is now logged in)
After community creation, setup_completed is set to true and /setup
proxies to Web SM for subsequent community creation."
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[babashka.http-client :as http]
[clojure.data.json :as json]
[ajet.chat.auth-gw.auth :as auth]
[ajet.chat.auth-gw.pages :as pages]))
;;; ---------------------------------------------------------------------------
;;; Setup state helpers
;;; ---------------------------------------------------------------------------
(defn setup-complete?
"Check if initial setup has been completed.
Uses the cached atom if available, otherwise queries the DB."
[{:keys [ds setup-complete-atom]}]
(if-let [cached @setup-complete-atom]
(= cached :true)
(let [val (auth/get-system-setting ds "setup_completed")
complete? (= val "true")]
(reset! setup-complete-atom (if complete? :true :false))
complete?)))
(defn- mark-setup-complete!
"Mark setup as completed in the database and update the cache."
[{:keys [ds setup-complete-atom]}]
(auth/set-system-setting! ds "setup_completed" "true")
(reset! setup-complete-atom :true)
(log/info "Setup wizard marked as complete"))
(defn reload-providers!
"Reload OAuth providers from DB into the cached atom."
[{:keys [ds oauth-providers-atom]}]
(let [providers (auth/list-oauth-providers ds)]
(reset! oauth-providers-atom providers)
(log/info "Reloaded" (count providers) "OAuth providers from DB")
providers))
;;; ---------------------------------------------------------------------------
;;; Wizard step determination
;;; ---------------------------------------------------------------------------
(defn- current-step
"Determine the current wizard step based on DB state.
- No enabled OAuth providers → :configure-providers
- Providers exist but no users → :awaiting-login (redirect to login)
- Users exist but setup not complete → :create-community"
[ds]
(let [provider-count (auth/count-oauth-providers ds)
user-count (auth/count-users ds)]
(cond
(zero? provider-count) :configure-providers
(zero? user-count) :awaiting-login
:else :create-community)))
;;; ---------------------------------------------------------------------------
;;; Response helpers
;;; ---------------------------------------------------------------------------
(defn- require-wizard-auth
"Validate session for authenticated wizard steps. Returns auth-info or nil."
[{:keys [ds config]} request]
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")]
(auth/validate-session ds (auth/extract-session-cookie request cookie-name))))
(def ^:private redirect-to-login
{:status 302 :headers {"Location" "/auth/login"} :body ""})
(def ^:private redirect-to-home
{:status 302 :headers {"Location" "/"} :body ""})
(def ^:private redirect-to-setup
{:status 302 :headers {"Location" "/setup"} :body ""})
(defn- html-response [body]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"
"Cache-Control" "no-store"}
:body body})
;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------
(defn wizard-page-handler
"GET /setup — render the appropriate wizard step.
- No providers configured: show OAuth provider configuration form
- Providers exist, no users: redirect to /auth/login for first OAuth login
- User authenticated, setup incomplete: show community creation form"
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [step (current-step ds)]
(case step
:configure-providers
(let [providers (auth/list-all-oauth-providers ds)]
(html-response (pages/setup-configure-providers-page {:providers providers})))
:awaiting-login
redirect-to-login
:create-community
(if-let [_auth (require-wizard-auth sys request)]
(html-response (pages/setup-create-community-page {}))
redirect-to-login)))))
(defn create-community-page-handler
"GET /setup/create-community — render the community creation form directly.
Used when navigating from provider config step via 'Next' link."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(if-let [_auth (require-wizard-auth sys request)]
(html-response (pages/setup-create-community-page {}))
redirect-to-login)))
(defn add-provider-handler
"POST /setup/providers — add an OAuth provider during initial setup.
No auth required (no users exist yet). Validates provider fields:
type must be github/gitea/oidc, display name + slug + client credentials
required, base-url required for Gitea, issuer-url required for OIDC."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [params (:params request)
provider-type (get params "provider-type")
display-name (some-> (get params "display-name") str/trim)
slug (some-> (get params "slug") str/trim)
client-id (some-> (get params "client-id") str/trim)
client-secret (some-> (get params "client-secret") str/trim)
base-url (some-> (get params "base-url") str/trim)
issuer-url (some-> (get params "issuer-url") str/trim)
providers (auth/list-all-oauth-providers ds)
render-error (fn [msg]
(html-response
(pages/setup-configure-providers-page
{:providers providers :error msg})))]
(cond
(not (#{"github" "gitea" "oidc"} provider-type))
(render-error "Invalid provider type")
(or (str/blank? display-name) (str/blank? slug)
(str/blank? client-id) (str/blank? client-secret))
(render-error "Display name, slug, client ID, and client secret are required")
(and (= provider-type "gitea") (str/blank? base-url))
(render-error "Base URL is required for Gitea providers")
(and (= provider-type "oidc") (str/blank? issuer-url))
(render-error "Issuer URL is required for OIDC providers")
:else
(do
(auth/insert-oauth-provider! ds
(cond-> {:provider-type provider-type
:display-name display-name
:slug slug
:client-id client-id
:client-secret client-secret}
(not (str/blank? base-url)) (assoc :base-url base-url)
(not (str/blank? issuer-url)) (assoc :issuer-url issuer-url)))
(reload-providers! sys)
(log/info "Setup wizard: OAuth provider added" provider-type slug)
redirect-to-setup)))))
(defn delete-provider-handler
"POST /setup/providers/:id/delete — remove an OAuth provider during setup.
No auth required (called during initial setup before any users exist)."
[{:keys [ds] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(let [provider-id (get-in request [:path-params :id])]
(auth/delete-oauth-provider! ds
(if (instance? java.util.UUID provider-id)
provider-id
(java.util.UUID/fromString (str provider-id))))
(reload-providers! sys)
(log/info "Setup wizard: OAuth provider deleted" provider-id)
redirect-to-setup)))
(defn create-community-handler
"POST /setup/create-community — create the first community and complete setup.
Requires authentication (admin logged in via OAuth). Validates community
name and slug, creates via internal API call, marks setup_completed=true."
[{:keys [ds config] :as sys} request]
(if (setup-complete? sys)
redirect-to-home
(if-let [auth-info (require-wizard-auth sys request)]
(let [params (:params request)
community-name (some-> (get params "name") str/trim)
community-slug (some-> (get params "slug") str/trim)]
(cond
(or (str/blank? community-name) (str/blank? community-slug))
(html-response (pages/setup-create-community-page
{:error "Community name and slug are required"}))
(not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" community-slug))
(html-response (pages/setup-create-community-page
{:error "Slug must be lowercase letters, digits, and hyphens (min 2 chars)"
:name community-name :slug community-slug}))
:else
(let [api-host (get-in config [:services :api :host] "localhost")
api-port (get-in config [:services :api :port] 3001)
api-url (str "http://" api-host ":" api-port "/api/communities")
resp (http/post api-url
{:headers {"Content-Type" "application/json"
"X-User-Id" (str (:user-id auth-info))}
:body (json/write-str {:name community-name
:slug community-slug})
:throw false
:timeout 10000})]
(if (= 201 (:status resp))
(do
(mark-setup-complete! sys)
(reload-providers! sys)
(log/info "Setup wizard: complete. Community created:" community-slug)
redirect-to-home)
(let [body (try (json/read-str (:body resp) :key-fn keyword) (catch Exception _ nil))
errmsg (or (get-in body [:error :message]) "Failed to create community")]
(html-response (pages/setup-create-community-page
{:error errmsg
:name community-name
:slug community-slug})))))))
redirect-to-login)))
+141
View File
@@ -0,0 +1,141 @@
{:tasks
{;; ---------------------------------------------------------------------------
;; Dev infrastructure (Docker)
;; ---------------------------------------------------------------------------
infra:dev
{:doc "Start dev infrastructure (Postgres, NATS, MinIO)"
:task (shell "docker compose -f docker-compose.dev.yml up -d")}
infra:dev:stop
{:doc "Stop dev infrastructure"
:task (shell "docker compose -f docker-compose.dev.yml down")}
infra:test
{:doc "Start test infrastructure (Postgres, NATS, MinIO on test ports)"
:task (shell "docker compose -f docker-compose.test.yml up -d")}
infra:test:stop
{:doc "Stop test infrastructure"
:task (shell "docker compose -f docker-compose.test.yml down")}
;; ---------------------------------------------------------------------------
;; Development
;; ---------------------------------------------------------------------------
dev
{:doc "Start dev infra + nREPL with all modules on the classpath"
:task (do (run 'infra:dev)
(shell "clj -M:dev -m nrepl.cmdline --middleware '[cider.nrepl/cider-middleware refactor-nrepl.middleware/wrap-refactor]'"))}
;; ---------------------------------------------------------------------------
;; Testing
;; ---------------------------------------------------------------------------
test
{:doc "Run all tests (starts test infra if needed)"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner"))}
test:unit
{:doc "Run unit tests only (no Docker needed)"
:task (shell "clj -M:test -m kaocha.runner --focus unit")}
test:integration
{:doc "Run integration tests (starts test infra if needed)"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus integration"))}
test:e2e
{:doc "Run e2e tests (starts test infra if needed)"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus e2e"))}
test:e2e:browser
{:doc "Run Playwright browser E2E tests (nbb + ClojureScript)"
:task (do
;; Start full e2e stack (infra + app services + Gitea)
(shell "docker compose -f docker-compose.test.yml --profile e2e up -d --build")
;; Install npm deps and Playwright browser
(shell "cd e2e && npm install && npx playwright install chromium")
;; Run the nbb test suite
(let [result (shell {:continue true} "cd e2e && npx nbb -cp src -m ajet-chat.e2e.runner")]
;; Tear down the stack
(shell "docker compose -f docker-compose.test.yml --profile e2e down -v")
(System/exit (:exit result))))}
;; Per-module test tasks
test:shared
{:doc "Run all shared module tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus shared"))}
test:shared:unit
{:doc "Run shared unit tests only"
:task (shell "clj -M:test -m kaocha.runner --focus shared-unit")}
test:shared:integration
{:doc "Run shared integration tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus shared-integration"))}
test:api
{:doc "Run all API module tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus api"))}
test:api:unit
{:doc "Run API unit tests only"
:task (shell "clj -M:test -m kaocha.runner --focus api-unit")}
test:api:integration
{:doc "Run API integration tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus api-integration"))}
test:auth-gw
{:doc "Run all auth-gw module tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus auth-gw"))}
test:auth-gw:integration
{:doc "Run auth-gw integration tests"
:task (do (run 'infra:test)
(shell "clj -M:test -m kaocha.runner --focus auth-gw-integration"))}
;; ---------------------------------------------------------------------------
;; Build
;; ---------------------------------------------------------------------------
build
{:doc "Build uberjar for a module. Usage: bb build <module>"
:task (let [module (first *command-line-args*)]
(when-not module
(println "Usage: bb build <module> (api | auth-gw | web-sm | tui-sm | cli)")
(System/exit 1))
(shell (str "clj -T:build uber :module " module)))}
clean
{:doc "Clean build artifacts for a module. Usage: bb clean <module>"
:task (let [module (first *command-line-args*)]
(if module
(shell (str "clj -T:build clean :module " module))
(shell "clj -T:build clean-all")))}
;; ---------------------------------------------------------------------------
;; Production (Docker Compose)
;; ---------------------------------------------------------------------------
prod
{:doc "Start production stack (docker compose)"
:task (shell "docker compose up -d --build")}
prod:stop
{:doc "Stop production stack"
:task (shell "docker compose down")}
prod:logs
{:doc "Tail production logs"
:task (shell "docker compose logs -f")}
;; ---------------------------------------------------------------------------
;; Database utilities
;; ---------------------------------------------------------------------------
db:reset-test
{:doc "Reset test database (drop & recreate public schema)"
:task (shell "docker exec ajet-chat-postgres-test-1 psql -U ajet -d ajet_chat_test -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'")}}}
+53
View File
@@ -0,0 +1,53 @@
(ns build
"tools.build configuration for building uberjars for each service."
(:require [clojure.tools.build.api :as b]))
(def version "0.1.0")
(defn- module-config [module]
(let [class-dir (str module "/target/classes")
basis (b/create-basis {:aliases [(keyword module)]})]
{:module module
:class-dir class-dir
:uber-file (str module "/target/" module ".jar")
:basis basis
:main (symbol (str "ajet.chat."
(case module
"api" "api.core"
"auth-gw" "auth-gw.core"
"web-sm" "web.core"
"tui-sm" "tui-sm.core"
"cli" "cli.core")))}))
(defn uber
"Build an uberjar for a service module.
Usage: clj -T:build uber :module api
clj -T:build uber :module auth-gw"
[{:keys [module]}]
(let [{:keys [class-dir uber-file basis main]} (module-config (name module))]
(println "Building uberjar for" (name module) "...")
(b/delete {:path class-dir})
(b/copy-dir {:src-dirs ["shared/src" "shared/resources"
(str (name module) "/src")
(str (name module) "/resources")]
:target-dir class-dir})
(b/compile-clj {:basis basis
:src-dirs ["shared/src" (str (name module) "/src")]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis basis
:main main})
(println "Built:" uber-file)))
(defn clean
"Clean build artifacts for a module."
[{:keys [module]}]
(b/delete {:path (str (name module) "/target")}))
(defn clean-all
"Clean all build artifacts."
[_]
(doseq [m ["api" "auth-gw" "web-sm" "tui-sm" "cli"]]
(b/delete {:path (str m "/target")})))
+126
View File
@@ -0,0 +1,126 @@
# CLI & TUI Client
Terminal client for ajet-chat. Provides two modes:
- **CLI Mode** — stateless one-shot commands for scripting and quick interactions
- **TUI Mode** — full interactive terminal application with split panes, markdown rendering, and real-time updates
## Usage
```bash
# CLI commands
ajet login # OAuth login
ajet communities # List communities
ajet channels # List channels
ajet read general # Read messages
ajet send general "hello" # Send a message
ajet tui # Launch interactive TUI
# TUI with options
ajet tui --community my-team # Open to specific community
ajet tui --channel general # Open to specific channel
```
## Dependencies
- `clojure-tui` — local dep at `../../clojure-tui` (Elm architecture TUI framework)
- `babashka.http-client` — HTTP client for API calls
- `clojure.data.json` — JSON parsing
- `clojure.tools.cli` — CLI argument parsing
- Shared modules: `api-client`, `markdown`, `mentions`
## TODO: clojure-tui Gaps
The TUI (`tui.clj`) uses clojure-tui's Elm architecture (init/update/view) for state management and rendering. However, several PRD features require capabilities that clojure-tui does not yet provide. SSE integration is worked around via a shared `LinkedBlockingQueue` polled every 100ms with `delayed-event`. Below is the complete list of gaps between the PRD requirements and clojure-tui's current capabilities.
### 1. Mouse Support (PRD 4.4)
**PRD requires:** Mouse click to select channel/message/button, mouse scroll in message list.
**Gap:** clojure-tui has no mouse tracking escape sequences in `terminal.clj` and no mouse event parsing in `input.clj`. The library only handles keyboard input.
**Workaround:** Keyboard-only navigation (Ctrl+N/P for channels, Tab for focus, arrow keys for scrolling).
**To resolve:** Add SGR mouse tracking (`\033[?1000h\033[?1006h`) to `terminal.clj` and mouse event parsing (button, position, scroll) to `input.clj`.
### 2. Inline Image Rendering (PRD 4.5)
**PRD requires:** Render images inline in message list via timg, sixel, or kitty graphics protocol.
**Gap:** clojure-tui has no image rendering support. Its render pipeline outputs ANSI text only.
**Workaround:** Images display as `[image: filename.png]` text placeholder.
**To resolve:** Add a `:image` render primitive that shells out to `timg` or emits sixel/kitty escape sequences. Requires terminal capability detection.
### 3. Multiline Text Input (PRD 4.4)
**PRD requires:** Shift+Enter or Alt+Enter inserts a newline in the input field.
**Gap:** clojure-tui's `:input` widget is single-line only. It handles backspace and character insertion but has no concept of line breaks within the input buffer.
**Workaround:** Messages are single-line only. No multiline composition.
**To resolve:** Extend `:input` widget to support a multi-line buffer with cursor movement across lines, or create a new `:textarea` widget.
### 4. Autocomplete Dropdowns (PRD 4.4)
**PRD requires:** Typing `@` shows user mention dropdown, `#` shows channel dropdown, `/` shows slash command list. Tab to select.
**Gap:** clojure-tui has no autocomplete or dropdown widget. It has `:modal` and `:scroll` primitives but no composition for filtered-list-as-you-type behavior.
**Workaround:** @mentions, #channels, and /commands are typed manually without autocomplete.
**To resolve:** Build an autocomplete widget by composing `:modal` + `:scroll` + filtered list, with keyboard navigation. This is application-level code that could be contributed back to clojure-tui.
### 5. SSE Client Integration (PRD 4.7)
**PRD requires:** Real-time event stream from TUI session manager via Server-Sent Events.
**Gap:** clojure-tui's event loop (`core.clj`) only processes keyboard input events. It has no mechanism to inject external events (HTTP responses, SSE data) into the Elm update cycle.
**Workaround:** A background thread reads SSE via `HttpURLConnection` and writes parsed events to a shared `LinkedBlockingQueue`. The Elm loop polls this queue every 100ms via `delayed-event`, draining events and processing them in the `:update` function. This works but adds up to 100ms latency.
**To resolve:** Add an external event channel to clojure-tui's `run` function (e.g., accept a `core.async` channel that the event loop merges with stdin input via `alt!`). This would eliminate polling and allow SSE events to flow through `:update` with zero latency.
### 6. Terminal Bell (PRD 4.8)
**PRD requires:** Terminal bell (`\a`) on new @mention or DM.
**Gap:** clojure-tui's render pipeline doesn't include bell output. Trivial to implement but not part of the library's event/render model.
**Workaround:** Not yet implemented. Can be added as `(print "\u0007") (flush)` in the message event handler.
**To resolve:** Either add a `:bell` event type to clojure-tui, or just emit the bell character directly in application code (outside the render cycle).
### 7. OSC 8 Hyperlinks (PRD 4.6)
**PRD requires:** URLs in messages render as clickable hyperlinks using OSC 8 escape sequences (`\033]8;;URL\033\\text\033]8;;\033\\`).
**Gap:** clojure-tui's `ansi.clj` has ANSI color/style codes but no OSC 8 hyperlink support.
**Workaround:** URLs render as plain underlined text without click behavior.
**To resolve:** Add OSC 8 hyperlink escape sequences to `ansi.clj` and integrate into the `:text` render primitive when a `:href` attribute is present.
### 8. Spoiler Text Reveal (PRD 4.6)
**PRD requires:** `||spoiler||` text renders hidden (e.g., as block characters) until user presses Enter on the selected message to reveal.
**Gap:** This is an application-level feature requiring per-message hidden/revealed state and keypress handling. clojure-tui doesn't prevent this but provides no specific support.
**Workaround:** Spoiler text renders as plain text (not hidden).
**To resolve:** Track revealed-spoiler state per message ID in app state. Render spoiler spans as `\u2588` block characters when hidden, original text when revealed. Toggle on Enter keypress when message is selected.
### Summary Table
| Feature | PRD Section | Status | Blocked By |
|---------|------------|--------|------------|
| Mouse support | 4.4 | Not implemented | clojure-tui: no mouse input |
| Inline images | 4.5 | Placeholder only | clojure-tui: no image rendering |
| Multiline input | 4.4 | Single-line only | clojure-tui: :input is single-line |
| Autocomplete | 4.4 | Not implemented | clojure-tui: no dropdown widget |
| SSE integration | 4.7 | Queue polling (100ms) | clojure-tui: no external event injection |
| Terminal bell | 4.8 | Not implemented | Trivial — just needs `\a` output |
| OSC 8 hyperlinks | 4.6 | Not implemented | clojure-tui: no OSC 8 support |
| Spoiler reveal | 4.6 | Plain text | Application-level (not blocked) |
+1
View File
@@ -1,5 +1,6 @@
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/tools.cli {:mvn/version "1.1.230"}
ajet/chat-shared {:local/root "../shared"}
ajet/clojure-tui {:local/root "../../clojure-tui"}}
:aliases
+269
View File
@@ -0,0 +1,269 @@
(ns ajet.chat.cli.auth
"Authentication commands for the CLI client.
Supports two login modes:
- Interactive OAuth: opens browser, starts temp HTTP callback server
- Token: saves an API token directly for scripting
Login flow:
1. Start a temporary local HTTP server on a random port
2. Open browser to auth gateway login with redirect to localhost callback
3. Capture session token from callback query params
4. Save to session.edn
5. Fetch /api/me to confirm and print user info
Fallback: if browser cannot open, print URL and prompt for manual token paste."
(:require [clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.shared.api-client :as api])
(:import [java.net ServerSocket URLDecoder]
[java.io BufferedReader InputStreamReader PrintWriter]
[java.time Instant Duration]))
;;; ---------------------------------------------------------------------------
;;; Local callback server
;;; ---------------------------------------------------------------------------
(defn- parse-query-params
"Parse query string into a map."
[query-string]
(when (and query-string (not (str/blank? query-string)))
(into {}
(map (fn [pair]
(let [parts (str/split pair #"=" 2)]
[(URLDecoder/decode (first parts) "UTF-8")
(when (second parts)
(URLDecoder/decode (second parts) "UTF-8"))]))
(str/split query-string #"&")))))
(defn- extract-request-info
"Extract method, path, and query params from an HTTP request line."
[request-line]
(when request-line
(let [parts (str/split request-line #"\s+")
method (first parts)
full-path (second parts)
[path query] (str/split (or full-path "/") #"\?" 2)]
{:method method
:path path
:query (parse-query-params query)})))
(defn- send-http-response
"Send a simple HTTP response on a PrintWriter."
[^PrintWriter writer status-code body]
(.println writer (str "HTTP/1.1 " status-code " OK"))
(.println writer "Content-Type: text/html; charset=utf-8")
(.println writer "Connection: close")
(.println writer (str "Content-Length: " (count (.getBytes ^String body "UTF-8"))))
(.println writer "")
(.print writer body)
(.flush writer))
(def ^:private success-page
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
<h1>Logged in!</h1>
<p>You can close this tab and return to your terminal.</p>
</body></html>")
(def ^:private error-page
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
<h1>Login failed</h1>
<p>Something went wrong. Please try again.</p>
</body></html>")
(defn- start-callback-server
"Start a temporary HTTP server on a random port. Blocks until a callback
is received or timeout (120s). Returns the captured query params or nil."
[]
(let [server (ServerSocket. 0)
port (.getLocalPort server)
result (promise)]
(.setSoTimeout server 120000) ;; 2 minute timeout
(future
(try
(let [socket (.accept server)
reader (BufferedReader. (InputStreamReader. (.getInputStream socket)))
writer (PrintWriter. (.getOutputStream socket) true)
line (.readLine reader)
info (extract-request-info line)]
(if (and info (get-in info [:query "token"]))
(do
(send-http-response writer 200 success-page)
(deliver result (:query info)))
(do
(send-http-response writer 400 error-page)
(deliver result nil)))
(.close socket))
(catch java.net.SocketTimeoutException _
(deliver result nil))
(catch Exception _e
(deliver result nil))
(finally
(.close server))))
{:port port :result result}))
;;; ---------------------------------------------------------------------------
;;; Browser opening
;;; ---------------------------------------------------------------------------
(defn- open-browser
"Attempt to open a URL in the default browser. Returns true on success."
[url]
(try
(let [os-name (str/lower-case (System/getProperty "os.name"))]
(cond
(str/includes? os-name "linux")
(do (.start (ProcessBuilder. ["xdg-open" url])) true)
(str/includes? os-name "mac")
(do (.start (ProcessBuilder. ["open" url])) true)
(str/includes? os-name "windows")
(do (.start (ProcessBuilder. ["cmd" "/c" "start" url])) true)
:else false))
(catch Exception _
false)))
;;; ---------------------------------------------------------------------------
;;; Login commands
;;; ---------------------------------------------------------------------------
(defn login-interactive
"Interactive OAuth login flow.
1. Starts a temporary local HTTP server on a random port
2. Opens browser to auth gateway login URL
3. Waits for callback with session token
4. Saves session and fetches user info"
[& [{:keys [json?]}]]
(let [server-url (config/get-server-url)
{:keys [port result]} (start-callback-server)
callback-url (str "http://localhost:" port "/callback")
login-url (str server-url "/auth/login?redirect=" callback-url "&cli=true")
browser-ok? (open-browser login-url)]
(if browser-ok?
(output/print-info "Opening browser for login...")
(output/print-info (str "Open this URL in your browser:\n " login-url)))
(output/print-info "\nIf your browser didn't open, visit:")
(output/print-info (str " " login-url))
(output/print-info "\nOr paste your session token below:")
(output/print-info "(Waiting for callback...)")
;; Start a parallel thread to accept manual token paste
(let [manual-token (promise)]
(future
(try
(when-let [line (read-line)]
(when-not (str/blank? line)
(deliver manual-token (str/trim line))))
(catch Exception _ nil)))
;; Wait for either callback or manual paste
(let [callback-result (deref result 120000 nil)
token (or (get callback-result "token")
(deref manual-token 100 nil))]
(if-not token
(do
(output/print-error "Login timed out"
"No callback received within 2 minutes."
"Try 'ajet login --token <token>' instead")
3)
;; Got a token - save session and verify
(let [expires-at (or (get callback-result "expires_at")
(str (.plus (Instant/now) (Duration/ofDays 30))))
user-id (get callback-result "user_id")
username (get callback-result "username")]
;; Save initial session
(config/save-session! {:token token
:user-id user-id
:username username
:expires-at expires-at})
;; Try to verify by fetching /api/me
(try
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(config/save-session! {:token token
:user-id (or (:id me) user-id)
:username (or (:username me) username)
:expires-at expires-at})
(if json?
(output/print-json me)
(output/print-success
(str "Logged in as "
(or (:display-name me) (:username me))
" (" (:username me) ")")))
0)
(catch Exception _
;; Token saved but couldn't verify - that's OK
(if json?
(output/print-json {:token token :username username})
(output/print-success
(str "Logged in" (when username (str " as " username)))))
0))))))))
(defn login-token
"Login with an API token directly (for scripting).
Saves the token and verifies it by calling /api/me."
[token & [{:keys [json?]}]]
(config/save-session! {:token token
:user-id nil
:username nil
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
(try
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(config/save-session! {:token token
:user-id (:id me)
:username (:username me)
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
(if json?
(output/print-json me)
(output/print-success
(str "Logged in as "
(or (:display-name me) (:username me))
" (" (:username me) ")")))
0)
(catch Exception e
(let [data (ex-data e)]
(if (= :ajet.chat/api-error (:type data))
(do
(output/print-error "Invalid token"
"The provided token was rejected by the server."
"Check the token and try again")
(config/clear-session!)
3)
(do
(output/print-error "Could not verify token"
(.getMessage e)
"Token saved, but server may be unreachable")
0))))))
(defn logout
"Clear the current session."
[& [{:keys [json?]}]]
(config/clear-session!)
(if json?
(output/print-json {:status "logged_out"})
(output/print-success "Logged out"))
0)
(defn whoami
"Fetch and display current user info."
[& [{:keys [json?]}]]
(let [ctx (config/make-ctx)
me (api/get-me ctx)]
(if json?
(output/print-json me)
(do
(output/print-info (str "Username: " (:username me)))
(output/print-info (str "Display Name: " (or (:display-name me) "-")))
(output/print-info (str "Email: " (or (:email me) "-")))
(output/print-info (str "User ID: " (:id me)))
(when (:status-text me)
(output/print-info (str "Status: " (:status-text me))))))
0))
+589
View File
@@ -0,0 +1,589 @@
(ns ajet.chat.cli.commands
"All CLI command implementations.
Every public function in this namespace follows the pattern:
(fn [args opts] ...) -> exit-code (int)
args: parsed positional arguments (vector of strings)
opts: parsed options map (includes :json? flag)
All API calls go through ajet.chat.shared.api-client.
All output goes through ajet.chat.cli.output."
(:require [clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.shared.api-client :as api])
(:import [java.io BufferedReader InputStreamReader]))
;;; ---------------------------------------------------------------------------
;;; Helpers
;;; ---------------------------------------------------------------------------
(defn- resolve-community-id
"Resolve a community slug or UUID to a community ID.
If community-slug is nil, uses the default community from config/state.
Returns the community map or throws."
[ctx community-slug]
(let [communities (:communities (api/get-communities ctx))
slug (or community-slug (config/get-default-community))]
(when-not slug
(throw (ex-info "No community specified"
{:type :ajet.chat/usage-error
:hint "Use --community <slug> or set a default with 'ajet config set default-community <slug>'"})))
(let [match (first (filter #(or (= (:slug %) slug)
(= (str (:id %)) slug))
communities))]
(when-not match
(throw (ex-info (str "Community not found: " slug)
{:type :ajet.chat/not-found
:hint "Run 'ajet communities' to see available communities"})))
match)))
(defn- resolve-channel
"Resolve a channel name to a channel map within a community.
Returns the channel map or throws."
[ctx community-id channel-name]
(let [channels (:channels (api/get-channels ctx community-id))
match (first (filter #(or (= (:name %) channel-name)
(= (str (:id %)) channel-name))
channels))]
(when-not match
(throw (ex-info (str "Channel not found: #" channel-name)
{:type :ajet.chat/not-found
:hint "Run 'ajet channels' to see available channels"})))
match))
(defn- read-stdin
"Read all of stdin and return as a string."
[]
(let [reader (BufferedReader. (InputStreamReader. System/in))
sb (StringBuilder.)]
(loop []
(let [line (.readLine reader)]
(when line
(when (pos? (.length sb))
(.append sb "\n"))
(.append sb line)
(recur))))
(str sb)))
(defn- save-last-community!
"Save the last-used community to state."
[community-id]
(let [state (config/load-state)]
(config/save-state! (assoc state :last-community (str community-id)))))
(defn- save-last-channel!
"Save the last-used channel for a community to state."
[community-id channel-id]
(let [state (config/load-state)]
(config/save-state!
(assoc-in state [:last-channels (str community-id)] (str channel-id)))))
;;; ---------------------------------------------------------------------------
;;; Communities
;;; ---------------------------------------------------------------------------
(defn communities
"List communities the current user belongs to."
[_args {:keys [json?]}]
(let [ctx (config/make-ctx)
result (api/get-communities ctx)
comms (or (:communities result) result)]
(if json?
(output/print-json result)
(if (empty? comms)
(output/print-info "You are not a member of any communities.")
(output/print-table
[["Name" :name] ["Slug" :slug] ["Role" :role] ["Members" :member-count]]
comms)))
0))
;;; ---------------------------------------------------------------------------
;;; Channels
;;; ---------------------------------------------------------------------------
(defn channels
"List, join, or leave channels in a community."
[_args {:keys [json? community join leave]}]
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)]
(save-last-community! comm-id)
(cond
join
(let [ch (resolve-channel ctx comm-id join)]
(api/join-channel ctx (:id ch))
(if json?
(output/print-json {:joined (:name ch)})
(output/print-success (str "Joined #" (:name ch))))
0)
leave
(let [ch (resolve-channel ctx comm-id leave)]
(api/leave-channel ctx (:id ch))
(if json?
(output/print-json {:left (:name ch)})
(output/print-success (str "Left #" (:name ch))))
0)
:else
(let [result (api/get-channels ctx comm-id)
chs (or (:channels result) result)]
(if json?
(output/print-json result)
(if (empty? chs)
(output/print-info "No channels found.")
(output/print-table
[["Name" :name] ["Type" :type] ["Topic" :topic] ["Members" :member-count]]
(map (fn [ch]
(-> ch
(update :name #(str "#" %))
(update :type #(or (some-> % name) "text"))
(update :topic #(or % ""))))
chs))))
0))))
;;; ---------------------------------------------------------------------------
;;; Messages
;;; ---------------------------------------------------------------------------
(defn read-messages
"Read messages from a channel."
[args {:keys [json? community limit before thread]}]
(let [channel-name (first args)]
(when-not channel-name
(throw (ex-info "Channel name required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet read <channel> [--limit N] [--before ID] [--thread ID]"})))
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
ch (resolve-channel ctx comm-id channel-name)
ch-id (:id ch)]
(save-last-community! comm-id)
(save-last-channel! comm-id ch-id)
(if thread
;; Read thread replies
(let [result (api/get-thread ctx thread)]
(if json?
(output/print-json result)
(let [messages (or (:messages result) result)]
(output/print-info (str "Thread in #" (:name ch)))
(output/print-info "")
(output/print-messages messages))))
;; Read channel messages
(let [opts (cond-> {}
limit (assoc :limit (parse-long (str limit)))
before (assoc :before before))
result (api/get-messages ctx ch-id opts)]
(if json?
(output/print-json result)
(let [messages (or (:messages result) result)]
(output/print-messages messages
{:channel-name (:name ch)
:channel-topic (:topic ch)})))))
0)))
(defn send-message
"Send a message to a channel."
[args {:keys [json? community stdin image]}]
(let [channel-name (first args)
message-text (if stdin
(read-stdin)
(str/join " " (rest args)))]
(when-not channel-name
(throw (ex-info "Channel name required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet send <channel> <message> [--stdin] [--image <path>]"})))
(when (and (not stdin) (str/blank? message-text))
(throw (ex-info "Message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet send <channel> <message> or echo 'msg' | ajet send <channel> --stdin"})))
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
ch (resolve-channel ctx comm-id channel-name)
ch-id (:id ch)]
(save-last-community! comm-id)
(save-last-channel! comm-id ch-id)
;; Upload image first if specified
(when image
(let [file (java.io.File. ^String image)]
(when-not (.exists file)
(throw (ex-info (str "File not found: " image)
{:type :ajet.chat/usage-error})))
(let [ext (str/lower-case (or (last (str/split image #"\.")) ""))
ct (case ext
"png" "image/png"
"jpg" "image/jpeg"
"jpeg" "image/jpeg"
"gif" "image/gif"
"webp" "image/webp"
(throw (ex-info (str "Unsupported image format: " ext)
{:type :ajet.chat/usage-error
:hint "Supported: png, jpg, jpeg, gif, webp"})))]
(api/upload-file ctx ch-id image ct))))
(let [body {:body-md message-text}
result (api/send-message ctx ch-id body)]
(if json?
(output/print-json result)
(output/print-success (str "Message sent to #" (:name ch))))
0))))
(defn edit-message
"Edit a message by ID."
[args {:keys [json?]}]
(let [msg-id (first args)
new-text (str/join " " (rest args))]
(when-not msg-id
(throw (ex-info "Message ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet edit <message-id> <new-text>"})))
(when (str/blank? new-text)
(throw (ex-info "New message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet edit <message-id> <new-text>"})))
(let [ctx (config/make-ctx)
result (api/edit-message ctx msg-id {:body-md new-text})]
(if json?
(output/print-json result)
(output/print-success "Message edited"))
0)))
(defn delete-message
"Delete a message by ID (with confirmation)."
[args {:keys [json? force]}]
(let [msg-id (first args)]
(when-not msg-id
(throw (ex-info "Message ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet delete <message-id>"})))
(when (and (not json?) (not force))
(when-not (output/confirm? "Are you sure you want to delete this message?")
(output/print-info "Cancelled.")
(throw (ex-info "Cancelled" {:type :ajet.chat/cancelled}))))
(let [ctx (config/make-ctx)
result (api/delete-message ctx msg-id)]
(if json?
(output/print-json (or result {:deleted msg-id}))
(output/print-success "Message deleted"))
0)))
;;; ---------------------------------------------------------------------------
;;; DMs
;;; ---------------------------------------------------------------------------
(defn list-dms
"List DM channels."
[_args {:keys [json?]}]
(let [ctx (config/make-ctx)
result (api/get-dms ctx)
dms (or (:dms result) (:channels result) result)]
(if json?
(output/print-json result)
(if (empty? dms)
(output/print-info "No DM conversations.")
(output/print-table
[["User" :display-name] ["Username" :username] ["Last Message" :last-message-preview] ["Time" :last-message-at]]
(map (fn [dm]
(-> dm
(update :display-name #(or % (:username dm) ""))
(update :username #(or % ""))
(update :last-message-preview #(or % ""))
(update :last-message-at #(if % (output/relative-time %) ""))))
dms))))
0))
(defn send-dm
"Send a DM to a user by username."
[args {:keys [json? read]}]
(let [username (first args)]
(when-not username
(throw (ex-info "Username required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
(let [ctx (config/make-ctx)]
(if read
;; Read DM conversation
(let [dm-result (api/create-dm ctx {:username username})
ch-id (or (:id dm-result) (:channel-id dm-result))
messages (api/get-messages ctx ch-id {:limit 50})]
(if json?
(output/print-json messages)
(let [msgs (or (:messages messages) messages)]
(output/print-info (str "DM with @" username))
(output/print-info "")
(output/print-messages msgs)))
0)
;; Send DM
(let [message-text (str/join " " (rest args))]
(when (str/blank? message-text)
(throw (ex-info "Message text required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
(let [dm-result (api/create-dm ctx {:username username})
ch-id (or (:id dm-result) (:channel-id dm-result))
result (api/send-message ctx ch-id {:body-md message-text})]
(if json?
(output/print-json result)
(output/print-success (str "DM sent to @" username)))
0))))))
;;; ---------------------------------------------------------------------------
;;; Notifications
;;; ---------------------------------------------------------------------------
(defn notifications
"List or manage notifications."
[_args {:keys [json? all mark-read]}]
(let [ctx (config/make-ctx)]
(if mark-read
;; Mark all as read
(let [result (api/mark-notifications-read ctx {:all true})]
(if json?
(output/print-json (or result {:marked-read true}))
(output/print-success "All notifications marked as read"))
0)
;; List notifications
(let [opts (if all {} {:unread true})
result (api/get-notifications ctx opts)
notifs (or (:notifications result) result)]
(if json?
(output/print-json result)
(if (empty? notifs)
(output/print-info (if all "No notifications." "No unread notifications."))
(doseq [n notifs]
(let [type-str (case (keyword (name (or (:type n) "")))
:mention "@mention"
:dm "DM"
:thread-reply "thread reply"
:invite "invite"
(str (:type n)))
read? (:read n)
marker (if read? " " "* ")
time-str (output/relative-time (:created-at n))]
(output/print-info
(str marker type-str " - " (or (:preview n) (:source-id n))
" " time-str))))))
0))))
;;; ---------------------------------------------------------------------------
;;; Search
;;; ---------------------------------------------------------------------------
(defn search
"Search messages and channels with filters."
[args {:keys [json? community channel from type]}]
(let [query (str/join " " args)]
(when (str/blank? query)
(throw (ex-info "Search query required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet search <query> [--channel <ch>] [--from <user>] [--type messages|channels]"})))
(let [ctx (config/make-ctx)
comm (when (or community channel)
(resolve-community-id ctx community))
comm-id (when comm (:id comm))
ch (when (and channel comm-id)
(resolve-channel ctx comm-id channel))
opts (cond-> {:q query}
comm-id (assoc :community-id comm-id)
ch (assoc :channel-id (:id ch))
from (assoc :from from)
type (assoc :type (keyword type)))
result (api/search ctx opts)]
(if json?
(output/print-json result)
(let [results (or (:results result) (:messages result) result)]
(if (empty? results)
(output/print-info "No results found.")
(do
(output/print-info (str "Search results for: " query))
(output/print-info "")
(output/print-messages results {:show-channel true})))))
0)))
;;; ---------------------------------------------------------------------------
;;; Presence & Status
;;; ---------------------------------------------------------------------------
(defn set-status
"Set or show the user's status text."
[args {:keys [json?]}]
(let [ctx (config/make-ctx)
status-text (str/join " " args)]
(if (str/blank? status-text)
;; Show current status
(let [me (api/get-me ctx)]
(if json?
(output/print-json {:status-text (:status-text me)})
(output/print-info
(if (:status-text me)
(str "Status: " (:status-text me))
"No status set.")))
0)
;; Set status
(let [result (api/update-me ctx {:status-text status-text})]
(if json?
(output/print-json result)
(output/print-success (str "Status set to: " status-text)))
0))))
(defn who-online
"Show online users in the current community."
[_args {:keys [json? community]}]
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
result (api/get-presence ctx comm-id)
users (or (:users result) (:online result) result)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(if (empty? users)
(output/print-info "No users online.")
(do
(output/print-info (str "Online in " (:name comm) ":"))
(output/print-info "")
(output/print-table
[["User" :display-name] ["Username" :username] ["Status" :status-text]]
(map (fn [u]
(-> u
(update :display-name #(or % (:username u) ""))
(update :username #(or % ""))
(update :status-text #(or % ""))))
users)))))
0))
;;; ---------------------------------------------------------------------------
;;; Invites
;;; ---------------------------------------------------------------------------
(defn invite
"Create, list, or revoke invites."
[args {:keys [json? community max-uses]}]
(let [subcommand (first args)]
(case subcommand
"create"
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
body (cond-> {}
max-uses (assoc :max-uses (parse-long (str max-uses))))
result (api/create-invite ctx comm-id body)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(let [code (or (:code result) (:id result))
server-url (config/get-server-url)]
(output/print-success "Invite created")
(output/print-info (str " Link: " server-url "/invite/" code))
(output/print-info (str " Code: " code))
(when max-uses
(output/print-info (str " Max uses: " max-uses)))))
0)
"list"
(let [ctx (config/make-ctx)
comm (resolve-community-id ctx community)
comm-id (:id comm)
result (api/get-invites ctx comm-id)
invites (or (:invites result) result)]
(save-last-community! comm-id)
(if json?
(output/print-json result)
(if (empty? invites)
(output/print-info "No active invites.")
(output/print-table
[["Code" :code] ["Uses" :uses] ["Max" :max-uses] ["Created" :created-at] ["Expires" :expires-at]]
(map (fn [inv]
(-> inv
(update :uses #(or (str %) "0"))
(update :max-uses #(if % (str %) "unlimited"))
(update :created-at #(if % (output/relative-time %) ""))
(update :expires-at #(if % (output/relative-time %) "never"))))
invites))))
0)
"revoke"
(let [invite-id (second args)]
(when-not invite-id
(throw (ex-info "Invite ID required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet invite revoke <invite-id>"})))
(let [ctx (config/make-ctx)
result (api/revoke-invite ctx invite-id)]
(if json?
(output/print-json (or result {:revoked invite-id}))
(output/print-success (str "Invite revoked: " invite-id)))
0))
;; Default: unknown subcommand
(throw (ex-info (str "Unknown invite subcommand: " (or subcommand ""))
{:type :ajet.chat/usage-error
:hint "Usage: ajet invite <create|list|revoke>"})))))
;;; ---------------------------------------------------------------------------
;;; Config
;;; ---------------------------------------------------------------------------
(defn config-cmd
"Show or set configuration values."
[args {:keys [json?]}]
(let [subcommand (first args)]
(case subcommand
"set"
(let [key-name (second args)
value (str/join " " (drop 2 args))]
(when-not key-name
(throw (ex-info "Config key required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config set <key> <value>"})))
(when (str/blank? value)
(throw (ex-info "Config value required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config set <key> <value>"})))
(let [cfg (config/load-config)
kw-key (keyword key-name)
;; Coerce value
coerced (cond
(= "true" value) true
(= "false" value) false
(re-matches #"\d+" value) (parse-long value)
:else value)
new-cfg (assoc cfg kw-key coerced)]
(config/save-config! new-cfg)
(if json?
(output/print-json {kw-key coerced})
(output/print-success (str "Set " key-name " = " (pr-str coerced))))
0))
"server"
(let [url (second args)]
(when-not url
(throw (ex-info "Server URL required"
{:type :ajet.chat/usage-error
:hint "Usage: ajet config server <url>"})))
(let [cfg (config/load-config)
new-cfg (assoc cfg :server-url url)]
(config/save-config! new-cfg)
(if json?
(output/print-json {:server-url url})
(output/print-success (str "Server URL set to: " url)))
0))
;; Default: show config
(let [cfg (config/load-config)]
(if json?
(output/print-json cfg)
(do
(output/print-info "Current configuration:")
(output/print-info "")
(doseq [[k v] (sort-by key cfg)]
(if (map? v)
(do
(output/print-info (str " " (name k) ":"))
(doseq [[k2 v2] (sort-by key v)]
(output/print-info (str " " (name k2) ": " (pr-str v2)))))
(output/print-info (str " " (name k) ": " (pr-str v)))))))
0))))
+182
View File
@@ -0,0 +1,182 @@
(ns ajet.chat.cli.config
"Configuration management for the CLI client.
Config directory: ~/.config/ajet-chat/
Files:
config.edn — server URL, default community, preferences
session.edn — session token + user info
state.edn — last community, last channel per community"
(:require [clojure.edn :as edn]
[clojure.java.io :as io]
[clojure.string :as str])
(:import [java.time Instant]))
;;; ---------------------------------------------------------------------------
;;; Paths
;;; ---------------------------------------------------------------------------
(def ^:private config-dir-path
"Path to the config directory."
(str (System/getProperty "user.home") "/.config/ajet-chat"))
(defn- config-file ^java.io.File [filename]
(io/file config-dir-path filename))
;;; ---------------------------------------------------------------------------
;;; Directory management
;;; ---------------------------------------------------------------------------
(defn ensure-config-dir!
"Create the config directory if it does not exist. Returns the path."
[]
(let [dir (io/file config-dir-path)]
(when-not (.exists dir)
(.mkdirs dir))
config-dir-path))
;;; ---------------------------------------------------------------------------
;;; Generic EDN read/write helpers
;;; ---------------------------------------------------------------------------
(defn- read-edn-file
"Read an EDN file. Returns nil if the file does not exist or is empty."
[filename]
(let [f (config-file filename)]
(when (.exists f)
(let [content (slurp f)]
(when-not (str/blank? content)
(edn/read-string content))))))
(defn- write-edn-file!
"Write data as EDN to a file. Creates the config directory if needed."
[filename data]
(ensure-config-dir!)
(spit (config-file filename) (pr-str data)))
(defn- delete-file!
"Delete a file if it exists."
[filename]
(let [f (config-file filename)]
(when (.exists f)
(.delete f))))
;;; ---------------------------------------------------------------------------
;;; Session management
;;; ---------------------------------------------------------------------------
(defn load-session
"Load session.edn. Returns nil if missing or expired.
Session shape:
{:token \"base64url-session-token\"
:user-id \"uuid\"
:username \"alice\"
:expires-at \"2026-03-19T...\"}"
[]
(when-let [session (read-edn-file "session.edn")]
(let [expires-at (:expires-at session)]
(if (and expires-at
(try
(.isBefore (Instant/parse expires-at) (Instant/now))
(catch Exception _ false)))
nil
session))))
(defn save-session!
"Write session data to session.edn.
data should contain :token, :user-id, :username, and optionally :expires-at."
[data]
(write-edn-file! "session.edn" data))
(defn clear-session!
"Delete session.edn, effectively logging out."
[]
(delete-file! "session.edn"))
;;; ---------------------------------------------------------------------------
;;; Config management
;;; ---------------------------------------------------------------------------
(def ^:private default-config
{:server-url "http://localhost:3000"
:default-community nil
:tui {:theme :dark
:image-viewer :timg
:mouse true
:timestamps :relative
:notifications :bell}})
(defn load-config
"Load config.edn, merged with defaults. Returns defaults if file missing."
[]
(let [file-config (read-edn-file "config.edn")]
(if file-config
(merge-with (fn [a b]
(if (and (map? a) (map? b))
(merge a b)
b))
default-config file-config)
default-config)))
(defn save-config!
"Write config data to config.edn."
[data]
(write-edn-file! "config.edn" data))
;;; ---------------------------------------------------------------------------
;;; State management
;;; ---------------------------------------------------------------------------
(defn load-state
"Load state.edn (last community, last channel per community).
State shape:
{:last-community \"uuid\"
:last-channels {\"community-uuid\" \"channel-uuid\"}}"
[]
(or (read-edn-file "state.edn")
{:last-community nil
:last-channels {}}))
(defn save-state!
"Write state data to state.edn."
[data]
(write-edn-file! "state.edn" data))
;;; ---------------------------------------------------------------------------
;;; Context builder
;;; ---------------------------------------------------------------------------
(defn make-ctx
"Build an API client context map from session + config.
Returns a ctx suitable for ajet.chat.shared.api-client functions:
{:base-url \"http://localhost:3000\"
:auth-token \"base64url-token\"
:user-id \"uuid\"
:trace-id \"uuid\"}
Throws ex-info with :type :ajet.chat/auth-error if no valid session."
[]
(let [config (load-config)
session (load-session)]
(when-not session
(throw (ex-info "Not logged in"
{:type :ajet.chat/auth-error
:hint "Run 'ajet login' to sign in"})))
{:base-url (:server-url config)
:auth-token (:token session)
:user-id (:user-id session)
:trace-id (str (java.util.UUID/randomUUID))}))
(defn get-server-url
"Get the configured server URL."
[]
(:server-url (load-config)))
(defn get-default-community
"Get the default community slug from config, or the last-used community from state."
[]
(or (:default-community (load-config))
(:last-community (load-state))))
+398 -3
View File
@@ -1,5 +1,400 @@
(ns ajet.chat.cli.core
"CLI client using clojure-tui.")
"Main entry point and command dispatcher for the ajet CLI client.
(defn -main [& _args]
(println "ajet-chat CLI starting..."))
Parses args with tools.cli, dispatches to subcommands:
login, logout, whoami, communities, channels, read, send, edit, delete,
dms, dm, notifications, search, status, who, invite, config, tui
Global options:
--json Output JSON (for scripting)
--help Show usage help
Exit codes:
0 = success
1 = general error
2 = usage error
3 = auth error
4 = permission error
5 = not found
130 = SIGINT"
(:require [clojure.tools.cli :as cli]
[ajet.chat.cli.auth :as auth]
[ajet.chat.cli.commands :as commands]
[ajet.chat.cli.config :as config]
[ajet.chat.cli.output :as output]
[ajet.chat.cli.tui :as tui])
(:gen-class))
;;; ---------------------------------------------------------------------------
;;; CLI option specs
;;; ---------------------------------------------------------------------------
(def ^:private global-options
[["-j" "--json" "Output JSON (for scripting)"]
["-h" "--help" "Show help"]])
(def ^:private login-options
[["-t" "--token TOKEN" "Login with an API token directly"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private channels-options
[["-c" "--community SLUG" "Community slug"]
[nil "--join CHANNEL" "Join a channel by name"]
[nil "--leave CHANNEL" "Leave a channel by name"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private read-options
[["-c" "--community SLUG" "Community slug"]
["-l" "--limit N" "Number of messages to fetch" :default 50 :parse-fn parse-long]
["-b" "--before ID" "Fetch messages before this message ID"]
["-t" "--thread ID" "Read thread replies for a message"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private send-options
[["-c" "--community SLUG" "Community slug"]
["-s" "--stdin" "Read message from stdin"]
["-i" "--image PATH" "Attach an image file"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private edit-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private delete-options
[["-f" "--force" "Skip confirmation"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private dm-options
[["-r" "--read" "Read DM conversation instead of sending"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private notifications-options
[["-a" "--all" "Show all notifications (not just unread)"]
["-m" "--mark-read" "Mark all notifications as read"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private search-options
[["-c" "--community SLUG" "Community slug"]
[nil "--channel CHANNEL" "Search in specific channel"]
[nil "--from USER" "Search by author"]
[nil "--type TYPE" "Filter by type (messages, channels)"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private status-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private who-options
[["-c" "--community SLUG" "Community slug"]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private invite-options
[["-c" "--community SLUG" "Community slug"]
[nil "--max-uses N" "Maximum number of invite uses" :parse-fn parse-long]
["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private config-options
[["-j" "--json" "Output JSON"]
["-h" "--help" "Show help"]])
(def ^:private tui-options
[["-c" "--community SLUG" "Open to specific community"]
[nil "--channel CHANNEL" "Open to specific channel"]
["-h" "--help" "Show help"]])
;;; ---------------------------------------------------------------------------
;;; Usage / help text
;;; ---------------------------------------------------------------------------
(def ^:private program-name "ajet")
(def ^:private usage-header
(str program-name " - ajet chat CLI client\n"
"\n"
"Usage: " program-name " <command> [options] [args]\n"
"\n"
"Commands:\n"
" login Login via OAuth or API token\n"
" logout Clear session\n"
" whoami Show current user info\n"
" communities List communities\n"
" channels List/join/leave channels\n"
" read <channel> Read messages in a channel\n"
" send <channel> Send a message\n"
" edit <id> <text> Edit a message\n"
" delete <id> Delete a message\n"
" dms List DM conversations\n"
" dm <user> <text> Send a DM\n"
" notifications Manage notifications\n"
" search <query> Search messages\n"
" status [text] Show/set status\n"
" who Show online users\n"
" invite Manage invites (create/list/revoke)\n"
" config Show/set configuration\n"
" tui Launch interactive TUI\n"
"\n"
"Global options:\n"
" -j, --json Output JSON (for scripting)\n"
" -h, --help Show help\n"
"\n"
"Run '" program-name " <command> --help' for command-specific options."))
(defn- command-usage
"Generate usage text for a specific command."
[command options-spec description]
(let [{:keys [summary]} (cli/parse-opts [] options-spec)]
(str "Usage: " program-name " " command " " description "\n"
"\n"
"Options:\n"
summary)))
;;; ---------------------------------------------------------------------------
;;; Error handling
;;; ---------------------------------------------------------------------------
(defn- api-error->exit-code
"Map an API error status to an exit code."
[status]
(cond
(= status 401) 3
(= status 403) 4
(= status 404) 5
(>= status 500) 1
:else 1))
(defn- api-error->hint
"Generate a hint for common API error statuses."
[status]
(case status
401 "Run 'ajet login' to sign in"
403 "You don't have permission for this action"
404 nil
429 "Too many requests. Wait a moment and try again"
nil))
(defn- handle-error
"Handle an exception and return an exit code."
[e]
(let [data (ex-data e)]
(case (:type data)
:ajet.chat/api-error
(let [status (:status data)
body (:body data)
msg (or (:message body) (:error body) (.getMessage e))
detail (or (:detail body) (:details body))
hint (or (:hint data) (api-error->hint status))]
(output/print-error msg detail hint)
(api-error->exit-code status))
:ajet.chat/auth-error
(do
(output/print-error (.getMessage e)
"No session token found. You need to authenticate first."
(or (:hint data) "Run 'ajet login' to sign in"))
3)
:ajet.chat/usage-error
(do
(output/print-error (.getMessage e) nil (:hint data))
2)
:ajet.chat/not-found
(do
(output/print-error (.getMessage e) nil (:hint data))
5)
:ajet.chat/validation-error
(do
(output/print-error (.getMessage e) (:explain data) nil)
2)
:ajet.chat/cancelled
0
;; Unknown ex-info
(if data
(do
(output/print-error (.getMessage e) (pr-str data) nil)
1)
;; Regular exception
(do
(output/print-error (.getMessage e)
nil
"If this persists, check your server connection with 'ajet config'")
1)))))
;;; ---------------------------------------------------------------------------
;;; Command dispatch
;;; ---------------------------------------------------------------------------
(defn- parse-and-dispatch
"Parse command-specific options and dispatch to the handler."
[handler-fn raw-args option-spec]
(let [{:keys [options arguments errors summary]} (cli/parse-opts raw-args option-spec)]
(cond
(:help options)
(do (println summary) 0)
errors
(do
(doseq [err errors]
(output/print-error err))
2)
:else
(handler-fn arguments (assoc options :json? (:json options))))))
(defn- dispatch
"Dispatch to the appropriate command handler."
[command raw-args]
(case command
"login"
(let [{:keys [options errors]} (cli/parse-opts raw-args login-options)]
(cond
errors (do (doseq [e errors] (output/print-error e)) 2)
(:help options) (do (println (command-usage "login" login-options "[options]")) 0)
(:token options) (auth/login-token (:token options) {:json? (:json options)})
:else (auth/login-interactive {:json? (:json options)})))
"logout"
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
(if (:help options)
(do (println (command-usage "logout" global-options "")) 0)
(auth/logout {:json? (:json options)})))
"whoami"
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
(if (:help options)
(do (println (command-usage "whoami" global-options "")) 0)
(auth/whoami {:json? (:json options)})))
"communities"
(parse-and-dispatch commands/communities raw-args global-options)
"channels"
(parse-and-dispatch commands/channels raw-args channels-options)
"read"
(parse-and-dispatch commands/read-messages raw-args read-options)
"send"
(parse-and-dispatch commands/send-message raw-args send-options)
"edit"
(parse-and-dispatch commands/edit-message raw-args edit-options)
"delete"
(parse-and-dispatch commands/delete-message raw-args delete-options)
"dms"
(parse-and-dispatch commands/list-dms raw-args global-options)
"dm"
(parse-and-dispatch commands/send-dm raw-args dm-options)
"notifications"
(parse-and-dispatch commands/notifications raw-args notifications-options)
"search"
(parse-and-dispatch commands/search raw-args search-options)
"status"
(parse-and-dispatch commands/set-status raw-args status-options)
"who"
(parse-and-dispatch commands/who-online raw-args who-options)
"invite"
(parse-and-dispatch commands/invite raw-args invite-options)
"config"
(parse-and-dispatch commands/config-cmd raw-args config-options)
"tui"
(let [{:keys [options errors]} (cli/parse-opts raw-args tui-options)]
(cond
errors (do (doseq [e errors] (output/print-error e)) 2)
(:help options) (do (println (command-usage "tui" tui-options "[options]")) 0)
:else (tui/launch! {:community (:community options)
:channel (:channel options)})))
;; Unknown command
(do
(output/print-error (str "Unknown command: " command)
nil
(str "Run '" program-name " --help' to see available commands"))
2)))
;;; ---------------------------------------------------------------------------
;;; Main entry point
;;; ---------------------------------------------------------------------------
(defn -main
"Main entry point. Parses arguments and dispatches to commands."
[& args]
;; Register SIGINT handler
(let [original-handler (Thread/getDefaultUncaughtExceptionHandler)]
(.addShutdownHook (Runtime/getRuntime)
(Thread. ^Runnable (fn []
;; Restore terminal on shutdown (in case TUI was running)
(print "\033[?25h\033[?1049l")
(flush)))))
(let [args (vec args)]
(if (empty? args)
;; No arguments: show help
(do
(println usage-header)
(System/exit 0))
;; Parse global options first to check for --help
(let [{:keys [options arguments]} (cli/parse-opts args global-options :in-order true)]
(cond
(:help options)
(do
(println usage-header)
(System/exit 0))
(empty? arguments)
(do
(println usage-header)
(System/exit 0))
:else
(let [command (first arguments)
cmd-args (vec (rest arguments))
;; Merge global --json flag into remaining args if present
all-args (if (:json options)
(into ["--json"] cmd-args)
cmd-args)
exit-code (try
(dispatch command all-args)
(catch clojure.lang.ExceptionInfo e
(handle-error e))
(catch java.net.ConnectException _e
(output/print-error
(str "Cannot connect to server at " (config/get-server-url))
"The server may be down or the URL may be incorrect."
"Check your config with 'ajet config'")
1)
(catch java.net.SocketTimeoutException _e
(output/print-error
"Request timed out"
"The server took too long to respond."
"Try again, or check your connection")
1)
(catch InterruptedException _e
130)
(catch Exception e
(handle-error e)))]
(System/exit (or exit-code 0))))))))
+296
View File
@@ -0,0 +1,296 @@
(ns ajet.chat.cli.output
"Output formatting for the CLI client.
All terminal output goes through this module for consistent formatting.
Supports human-readable and JSON output modes."
(:require [clojure.data.json :as json]
[clojure.string :as str]
[ajet.chat.shared.markdown :as markdown]
[ajet.chat.shared.mentions :as mentions])
(:import [java.time Instant Duration ZoneId]
[java.time.format DateTimeFormatter]))
;;; ---------------------------------------------------------------------------
;;; ANSI color helpers
;;; ---------------------------------------------------------------------------
(def ^:private ansi
{:reset "\033[0m"
:bold "\033[1m"
:dim "\033[2m"
:italic "\033[3m"
:underline "\033[4m"
:cyan "\033[36m"
:green "\033[32m"
:yellow "\033[33m"
:red "\033[31m"
:magenta "\033[35m"
:blue "\033[34m"
:gray "\033[90m"
:white "\033[37m"
:bg-red "\033[41m"})
(defn- colorize [color text]
(str (get ansi color "") text (:reset ansi)))
;;; ---------------------------------------------------------------------------
;;; Timestamp formatting
;;; ---------------------------------------------------------------------------
(def ^:private local-zone (ZoneId/systemDefault))
(def ^:private time-fmt
(DateTimeFormatter/ofPattern "h:mm a"))
(def ^:private date-fmt
(DateTimeFormatter/ofPattern "MMM d, yyyy"))
(def ^:private datetime-fmt
(DateTimeFormatter/ofPattern "MMM d, yyyy h:mm a"))
(defn- parse-timestamp
"Parse a timestamp string to an Instant. Handles ISO-8601 strings."
[ts]
(when ts
(try
(if (instance? Instant ts)
ts
(Instant/parse (str ts)))
(catch Exception _ nil))))
(defn relative-time
"Format a timestamp as a relative time string.
Returns: \"just now\", \"5m ago\", \"2h ago\", \"yesterday\", or a date."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [now (Instant/now)
duration (Duration/between instant now)
seconds (.toSeconds duration)
minutes (.toMinutes duration)
hours (.toHours duration)
days (.toDays duration)
zdt (.atZone instant local-zone)]
(cond
(< seconds 60) "just now"
(< minutes 60) (str minutes "m ago")
(< hours 24) (str hours "h ago")
(= days 1) "yesterday"
(< days 7) (str days "d ago")
:else (.format zdt date-fmt)))
""))
(defn- format-time
"Format a timestamp as a local time (e.g., '10:30 AM')."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [zdt (.atZone instant local-zone)]
(.format zdt time-fmt))
""))
(defn- format-datetime
"Format a timestamp as a local date and time."
[ts]
(if-let [instant (parse-timestamp ts)]
(let [zdt (.atZone instant local-zone)]
(.format zdt datetime-fmt))
""))
;;; ---------------------------------------------------------------------------
;;; Mention rendering
;;; ---------------------------------------------------------------------------
(defn- default-mention-lookup
"Default lookup function for mentions. Returns the ID as a fallback."
[_type id]
id)
;;; ---------------------------------------------------------------------------
;;; Message formatting
;;; ---------------------------------------------------------------------------
(defn- render-body
"Render a message body with markdown and mentions for terminal display."
[body-md & [mention-lookup]]
(let [lookup (or mention-lookup default-mention-lookup)
with-mentions (mentions/render body-md lookup)]
(markdown/->ansi with-mentions)))
(defn- format-attachments
"Format attachment list for display."
[attachments]
(when (seq attachments)
(str/join "\n"
(map (fn [att]
(str " " (colorize :cyan (str "[" (:content-type att "file") ": " (:filename att) "]"))))
attachments))))
(defn- format-reactions
"Format reactions for display."
[reactions]
(when (seq reactions)
(let [grouped (group-by :emoji reactions)]
(str " "
(str/join " "
(map (fn [[emoji reacts]]
(str emoji " " (count reacts)))
grouped))))))
(defn print-message
"Format and print a single message for terminal display.
Message shape:
{:id, :user-id, :username, :display-name, :body-md, :created-at,
:edited-at, :attachments, :reactions, :thread-count, :parent-id}"
[msg & [{:keys [mention-lookup show-channel]}]]
(let [author (or (:display-name msg) (:username msg) "unknown")
time-str (relative-time (:created-at msg))
edited? (:edited-at msg)
channel (when show-channel
(str (colorize :cyan (str "#" (:channel-name msg))) " "))
header (str " " (colorize :bold author) " "
(colorize :gray time-str)
(when edited? (colorize :gray " (edited)")))
body (render-body (:body-md msg) mention-lookup)
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))
atts (format-attachments (:attachments msg))
reacts (format-reactions (:reactions msg))
thread-ct (when (and (:thread-count msg) (pos? (:thread-count msg)))
(str " " (colorize :blue (str (:thread-count msg) " replies"))))]
(println (str (when channel (str channel "\n")) header))
(println indented)
(when atts (println atts))
(when reacts (println reacts))
(when thread-ct (println thread-ct))))
(defn print-messages
"Format and print a list of messages with grouping.
Messages from the same user within 5 minutes are grouped together
(only the first gets the full header)."
[messages & [{:keys [mention-lookup channel-name channel-topic] :as opts}]]
(when channel-name
(println (str (colorize :bold (str "#" channel-name))
(when channel-topic
(str (colorize :gray (str " -- " channel-topic))))))
(println))
(loop [msgs messages
prev-author nil
prev-time nil]
(when (seq msgs)
(let [msg (first msgs)
author (or (:username msg) (:user-id msg))
msg-time (parse-timestamp (:created-at msg))
same-group? (and (= author prev-author)
prev-time
msg-time
(< (.toMinutes (Duration/between prev-time msg-time)) 5))
display-name (or (:display-name msg) (:username msg) "unknown")
time-str (relative-time (:created-at msg))
edited? (:edited-at msg)]
(if same-group?
;; Grouped: just the body, indented
(let [body (render-body (:body-md msg) mention-lookup)
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))]
(println indented)
(when-let [atts (format-attachments (:attachments msg))]
(println atts))
(when-let [reacts (format-reactions (:reactions msg))]
(println reacts)))
;; New group: full header
(do
(when prev-author (println)) ;; blank line between groups
(print-message msg opts)))
(recur (rest msgs) author (or msg-time prev-time))))))
;;; ---------------------------------------------------------------------------
;;; Table formatting
;;; ---------------------------------------------------------------------------
(defn print-table
"Print a simple table from a list of maps.
columns is a vector of [header-string key-keyword] pairs.
Example: [[\"Name\" :name] [\"Slug\" :slug] [\"Members\" :member-count]]"
[columns rows]
(when (seq rows)
(let [headers (mapv first columns)
keys (mapv second columns)
str-rows (mapv (fn [row]
(mapv (fn [k] (str (get row k ""))) keys))
rows)
all-rows (cons headers str-rows)
col-widths (reduce (fn [widths row]
(mapv (fn [w cell] (max w (count cell))) widths row))
(mapv (constantly 0) headers)
all-rows)
fmt-row (fn [row]
(str/join " "
(map-indexed (fn [i cell]
(let [w (nth col-widths i)]
(format (str "%-" w "s") cell)))
row)))]
;; Header
(println (colorize :bold (fmt-row headers)))
;; Separator
(println (str/join " " (map #(apply str (repeat % "-")) col-widths)))
;; Data rows
(doseq [row str-rows]
(println (fmt-row row))))))
;;; ---------------------------------------------------------------------------
;;; Error formatting
;;; ---------------------------------------------------------------------------
(defn print-error
"Format and print an error message with optional hint.
Format:
Error: <short description>
<details>
Hint: <actionable next step>"
([message]
(print-error message nil nil))
([message details]
(print-error message details nil))
([message details hint]
(binding [*out* *err*]
(println (str (colorize :red "Error: ") message))
(when details
(println)
(println (str " " details)))
(when hint
(println)
(println (str (colorize :yellow "Hint: ") hint))))))
;;; ---------------------------------------------------------------------------
;;; JSON output
;;; ---------------------------------------------------------------------------
(defn print-json
"Print data as formatted JSON. Used with --json flag."
[data]
(println (json/write-str data)))
;;; ---------------------------------------------------------------------------
;;; Miscellaneous
;;; ---------------------------------------------------------------------------
(defn print-success
"Print a success message."
[message]
(println (str (colorize :green "OK") " " message)))
(defn print-info
"Print an informational message."
[message]
(println message))
(defn confirm?
"Ask for confirmation. Returns true if user types 'y' or 'yes'."
[prompt]
(print (str prompt " [y/N] "))
(flush)
(let [input (str/trim (or (read-line) ""))]
(contains? #{"y" "yes" "Y" "Yes" "YES"} input)))
+748
View File
@@ -0,0 +1,748 @@
(ns ajet.chat.cli.tui
"TUI mode — full interactive terminal application using clojure-tui Elm architecture.
Launched with `ajet tui`. Connects to the TUI session manager via SSE
for real-time events, and uses the API client for data operations.
Architecture: clojure-tui's Elm model (init/update/view) manages all state.
SSE events arrive via a shared LinkedBlockingQueue polled every 100ms.
API calls are dispatched as futures that write results back to the queue.
Layout (4 panes via :col/:row):
Header — app name, community name, connection status, username
Sidebar — communities, channels, DMs with unread counts
Content — scrollable message list + typing indicator
Input — message composition line
Status — keybindings and focus indicator
TODO: clojure-tui gaps — features that cannot be implemented with
the current library (see cli/README.md for full details):
1. Mouse support (PRD 4.4) — no mouse tracking/parsing in clojure-tui
2. Inline image rendering (PRD 4.5) — no timg/sixel/kitty support
3. Multiline text input (PRD 4.4) — :input widget is single-line only
4. Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
5. SSE client integration (PRD 4.7) — workaround: external queue + polling
6. Terminal bell (PRD 4.8) — trivial but outside render model
7. OSC 8 hyperlinks (PRD 4.6) — no OSC 8 in tui.ansi
8. Spoiler text reveal (PRD 4.6) — needs per-message hidden state"
(:require [tui.core :as tui]
[tui.events :as ev]
[tui.ansi :as ansi]
[babashka.http-client :as http]
[clojure.data.json :as json]
[clojure.string :as str]
[ajet.chat.cli.config :as config]
[ajet.chat.shared.api-client :as api]
[ajet.chat.shared.markdown :as markdown]
[ajet.chat.shared.mentions :as mentions])
(:import [java.io BufferedReader InputStreamReader]
[java.net HttpURLConnection URL]
[java.util ArrayList]
[java.util.concurrent LinkedBlockingQueue]))
;;; ---------------------------------------------------------------------------
;;; SSE parsing (pure)
;;; ---------------------------------------------------------------------------
(defn- parse-sse-event
"Parse accumulated SSE lines into {:event type :data parsed-json}."
[lines]
(when (seq lines)
(let [result (reduce
(fn [acc line]
(cond
(str/starts-with? line "event:")
(assoc acc :event (str/trim (subs line 6)))
(str/starts-with? line "data:")
(update acc :data-lines conj (str/trim (subs line 5)))
(str/starts-with? line "id:")
(assoc acc :id (str/trim (subs line 3)))
:else acc))
{:event "message" :data-lines [] :id nil}
lines)
data-str (str/join "\n" (:data-lines result))]
(when-not (str/blank? data-str)
{:event (:event result)
:data (try (json/read-str data-str :key-fn keyword)
(catch Exception _ {:raw data-str}))
:id (:id result)}))))
;;; ---------------------------------------------------------------------------
;;; SSE background thread (writes to shared queue)
;;; ---------------------------------------------------------------------------
(defn- connect-sse
"Open SSE connection. Returns {:connection :reader} or nil."
[base-url token community-id]
(let [url-str (str base-url "/tui/sse/events"
(when community-id
(str "?community_id=" community-id)))]
(try
(let [url (URL. url-str)
conn ^HttpURLConnection (.openConnection url)]
(.setRequestMethod conn "GET")
(.setRequestProperty conn "Accept" "text/event-stream")
(.setRequestProperty conn "Authorization" (str "Bearer " token))
(.setRequestProperty conn "Cache-Control" "no-cache")
(.setConnectTimeout conn 10000)
(.setReadTimeout conn 0)
(.connect conn)
(when (= 200 (.getResponseCode conn))
{:connection conn
:reader (BufferedReader.
(InputStreamReader. (.getInputStream conn)))}))
(catch Exception _ nil))))
(defn- sse-read-loop!
"Read SSE stream, put parsed events on queue. Blocks until disconnect."
[^BufferedReader reader ^LinkedBlockingQueue queue running?]
(try
(loop [lines []]
(when @running?
(let [line (.readLine reader)]
(if (nil? line)
(.put queue {:type :sse :event "disconnected" :data {}})
(if (str/blank? line)
(do
(when-let [event (parse-sse-event lines)]
(.put queue {:type :sse
:event (:event event)
:data (:data event)}))
(recur []))
(recur (conj lines line)))))))
(catch Exception _
(.put queue {:type :sse :event "disconnected" :data {}}))))
(defn- start-sse-thread!
"Start background SSE connection manager with auto-reconnect."
[base-url token ^LinkedBlockingQueue queue running?]
(future
(loop [backoff 1000]
(when @running?
(let [conn-info (connect-sse base-url token nil)]
(if conn-info
(do
(.put queue {:type :sse-status :connected true :reconnecting false})
(sse-read-loop! (:reader conn-info) queue running?)
(try (.close ^java.io.Closeable (:reader conn-info))
(catch Exception _ nil))
(when @running?
(.put queue {:type :sse-status :connected false :reconnecting true})
(Thread/sleep 1000)
(recur 1000)))
(do
(.put queue {:type :sse-status :connected false :reconnecting true})
(Thread/sleep (min backoff 30000))
(recur (min (* backoff 2) 30000)))))))))
;;; ---------------------------------------------------------------------------
;;; Side-effect dispatchers (futures that write results to queue)
;;; ---------------------------------------------------------------------------
(defn- fire-load-data!
"Load communities, channels, messages in background."
[ctx ^LinkedBlockingQueue queue {:keys [community channel]}]
(future
(try
(let [saved (config/load-state)
result (api/get-communities ctx)
comms (vec (or (:communities result) result))
;; Resolve community
comm-id (or (when community
(some #(when (or (= (:slug %) community)
(= (str (:id %)) community))
(str (:id %)))
comms))
(:last-community saved)
(when (seq comms) (str (:id (first comms)))))]
(if comm-id
(let [ch-result (api/get-channels ctx comm-id)
chs (vec (or (:channels ch-result) ch-result))
;; Resolve channel
ch-id (or (when channel
(some #(when (= (:name %) channel)
(str (:id %)))
chs))
(get-in saved [:last-channels comm-id])
(when (seq chs) (str (:id (first chs)))))
msgs (when ch-id
(let [r (api/get-messages ctx ch-id {:limit 50})]
(vec (or (:messages r) r))))]
(.put queue {:type :data-loaded
:communities comms
:channels chs
:active-community comm-id
:active-channel ch-id
:messages (or msgs [])}))
(.put queue {:type :data-loaded
:communities comms
:channels []
:active-community nil
:active-channel nil
:messages []})))
(catch Exception e
(.put queue {:type :data-error :error (.getMessage e)})))))
(defn- fire-switch-channel!
"Load messages for a channel + notify TUI SM."
[ctx ch-id ^LinkedBlockingQueue queue]
(future
(try
(let [result (api/get-messages ctx ch-id {:limit 50})
msgs (vec (or (:messages result) result))]
(.put queue {:type :channel-messages :channel-id ch-id :messages msgs}))
(catch Exception _ nil))
(try
(http/request
{:method :post
:uri (str (:base-url ctx) "/tui/navigate")
:headers {"Authorization" (str "Bearer " (:auth-token ctx))
"Content-Type" "application/json"}
:body (json/write-str {:channel-id ch-id})
:throw false})
(catch Exception _ nil))))
(defn- fire-send-message!
[ctx channel-id text ^LinkedBlockingQueue queue]
(future
(try
(api/send-message ctx channel-id {:body-md text})
(catch Exception e
(.put queue {:type :send-error :error (.getMessage e)})))))
(defn- fire-load-older!
[ctx channel-id before-id ^LinkedBlockingQueue queue]
(future
(try
(let [result (api/get-messages ctx channel-id {:limit 50 :before before-id})
msgs (vec (or (:messages result) result))]
(when (seq msgs)
(.put queue {:type :older-messages :messages msgs})))
(catch Exception _ nil))))
;;; ---------------------------------------------------------------------------
;;; Queue draining
;;; ---------------------------------------------------------------------------
(defn- drain-queue
"Drain all available events from queue into a vector."
[^LinkedBlockingQueue queue]
(let [buf (ArrayList.)]
(.drainTo queue buf)
(vec buf)))
;;; ---------------------------------------------------------------------------
;;; Model
;;; ---------------------------------------------------------------------------
(defn- initial-model
[ctx session ^LinkedBlockingQueue queue overrides]
{:ctx ctx
:session session
:queue queue
:overrides overrides ;; {:community ... :channel ...} for initial load
:connected false
:reconnecting false
;; Data
:communities []
:channels []
:messages []
:typing-users #{}
:unread-counts {}
;; Navigation
:active-community nil
:active-channel nil
;; Input — TODO: Multiline input (PRD 4.4) — :input is single-line only
:input-text ""
;; Focus: :input | :messages | :sidebar
:focus :input
;; Layout
:sidebar-width 22
:scroll-offset 0
;; User
:username (:username session)
:user-id (:user-id session)
;; Errors
:error-message nil
:loading false})
;;; ---------------------------------------------------------------------------
;;; Pure model helpers
;;; ---------------------------------------------------------------------------
(defn- community-name [model]
(or (some #(when (= (str (:id %)) (str (:active-community model)))
(:name %))
(:communities model))
"ajet chat"))
(defn- channel-name [model]
(or (some #(when (= (str (:id %)) (str (:active-channel model)))
(:name %))
(:channels model))
"?"))
(defn- channel-ids [model]
(mapv #(str (:id %)) (:channels model)))
(defn- text-channels [model]
(filterv #(or (= (:type %) "text") (= (:type %) :text) (nil? (:type %)))
(:channels model)))
(defn- dm-channels [model]
(filterv #(or (= (:type %) "dm") (= (:type %) :dm)
(= (:type %) "group-dm") (= (:type %) :group-dm))
(:channels model)))
(defn- adjacent-channel [model direction]
(let [ids (channel-ids model)
idx (.indexOf ^java.util.List ids (str (:active-channel model)))]
(when (seq ids)
(nth ids (case direction
:next (if (< idx (dec (count ids))) (inc idx) 0)
:prev (if (pos? idx) (dec idx) (dec (count ids))))))))
;;; ---------------------------------------------------------------------------
;;; SSE event → model (pure)
;;; ---------------------------------------------------------------------------
(defn- apply-sse-event
"Apply a single SSE event to the model. Returns {:model m :events []}."
[model sse-event]
(let [event-name (:event sse-event)
data (:data sse-event)]
(case event-name
"init"
{:model (merge model
{:communities (or (:communities data) (:communities model))
:channels (or (:channels data) (:channels model))
:active-community (or (:active-community data) (:active-community model))
:active-channel (or (:active-channel data) (:active-channel model))
:unread-counts (or (:unread-counts data) (:unread-counts model))})
:events []}
"message"
(let [ch-id (or (:channel-id data) (:channel_id data))]
;; TODO: Terminal bell (PRD 4.8) — emit \a on @mention or DM
{:model (if (= ch-id (:active-channel model))
(update model :messages conj data)
(update-in model [:unread-counts (str ch-id)] (fnil inc 0)))
:events []})
"message.update"
(let [msg-id (or (:id data) (:message-id data))]
{:model (update model :messages
(fn [msgs] (mapv #(if (= (:id %) msg-id) (merge % data) %) msgs)))
:events []})
"message.delete"
(let [msg-id (or (:id data) (:message-id data))]
{:model (update model :messages
(fn [msgs] (filterv #(not= (:id %) msg-id) msgs)))
:events []})
"typing"
(let [username (or (:username data) (:user data))]
(if (and username (not= username (:username model)))
{:model (update model :typing-users conj username)
:events [(ev/delayed-event 5000 {:type :clear-typing :username username})]}
{:model model :events []}))
"channel.update"
{:model (update model :channels
(fn [chs] (mapv #(if (= (:id %) (:id data)) (merge % data) %) chs)))
:events []}
;; Unknown / disconnected — ignore
{:model model :events []})))
;;; ---------------------------------------------------------------------------
;;; Queue event → model (pure, except for typing events)
;;; ---------------------------------------------------------------------------
(defn- process-queue-event
"Process a single queue event. Returns {:model m :events []}."
[model event]
(case (:type event)
:sse-status
{:model (assoc model :connected (:connected event) :reconnecting (:reconnecting event))
:events []}
:sse
(apply-sse-event model event)
:data-loaded
{:model (merge model (select-keys event [:communities :channels :active-community
:active-channel :messages])
{:loading false})
:events []}
:data-error
{:model (assoc model :error-message (:error event) :loading false)
:events []}
:channel-messages
{:model (if (= (:channel-id event) (:active-channel model))
(assoc model :messages (:messages event))
model)
:events []}
:older-messages
{:model (update model :messages #(vec (concat (:messages event) %)))
:events []}
:send-error
{:model (assoc model :error-message (:error event))
:events []}
;; Unknown
{:model model :events []}))
;;; ---------------------------------------------------------------------------
;;; Update function (Elm architecture)
;;; ---------------------------------------------------------------------------
(defn- tui-update
[{:keys [model event]}]
(let [queue ^LinkedBlockingQueue (:queue model)
ctx (:ctx model)]
(cond
;; ── Poll SSE queue ──────────────────────────────────────────────
(= (:type event) :sse-poll)
(let [q-events (drain-queue queue)
{:keys [m extra-events]}
(reduce (fn [{:keys [m extra-events]} e]
(let [result (process-queue-event m e)]
{:m (:model result)
:extra-events (into extra-events (:events result))}))
{:m model :extra-events []}
q-events)]
{:model m
:events (into [(ev/delayed-event 100 {:type :sse-poll})]
extra-events)})
;; ── Load initial data ───────────────────────────────────────────
(= (:type event) :load-initial-data)
(do
(fire-load-data! ctx queue (:overrides model))
{:model (assoc model :loading true)})
;; ── Clear typing indicator ──────────────────────────────────────
(= (:type event) :clear-typing)
{:model (update model :typing-users disj (:username event))}
;; ── Ctrl+Q / Ctrl+C → quit ─────────────────────────────────────
(or (ev/key= event \q #{:ctrl})
(ev/key= event \c #{:ctrl}))
{:model model :events [(ev/quit)]}
;; ── Ctrl+K → search (placeholder) ──────────────────────────────
(ev/key= event \k #{:ctrl})
{:model model}
;; ── Ctrl+N → next channel ──────────────────────────────────────
(ev/key= event \n #{:ctrl})
(if-let [ch (adjacent-channel model :next)]
(do
(fire-switch-channel! ctx ch queue)
{:model (assoc model
:active-channel ch
:messages []
:scroll-offset 0
:typing-users #{})})
{:model model})
;; ── Ctrl+P → previous channel ──────────────────────────────────
(ev/key= event \p #{:ctrl})
(if-let [ch (adjacent-channel model :prev)]
(do
(fire-switch-channel! ctx ch queue)
{:model (assoc model
:active-channel ch
:messages []
:scroll-offset 0
:typing-users #{})})
{:model model})
;; ── Ctrl+E → edit (placeholder) ────────────────────────────────
(ev/key= event \e #{:ctrl})
{:model model}
;; ── Ctrl+D → delete (placeholder) ──────────────────────────────
(ev/key= event \d #{:ctrl})
{:model model}
;; ── Ctrl+R → react (placeholder) ───────────────────────────────
(ev/key= event \r #{:ctrl})
{:model model}
;; ── Ctrl+T → thread (placeholder) ──────────────────────────────
(ev/key= event \t #{:ctrl})
{:model model}
;; ── Tab → cycle focus ──────────────────────────────────────────
(ev/key= event :tab)
{:model (update model :focus
{:input :messages, :messages :sidebar, :sidebar :input})}
;; ── Enter → send message ───────────────────────────────────────
(ev/key= event :enter)
(if (and (= (:focus model) :input)
(not (str/blank? (:input-text model))))
(do
(fire-send-message! ctx (:active-channel model)
(:input-text model) queue)
{:model (assoc model :input-text "" :error-message nil)})
{:model model})
;; ── Backspace ──────────────────────────────────────────────────
(ev/key= event :backspace)
(if (and (= (:focus model) :input)
(pos? (count (:input-text model))))
{:model (update model :input-text #(subs % 0 (dec (count %))))}
{:model model})
;; ── Arrow up ───────────────────────────────────────────────────
(ev/key= event :up)
{:model (if (= (:focus model) :messages)
(update model :scroll-offset
#(min (inc %) (count (:messages model))))
model)}
;; ── Arrow down ─────────────────────────────────────────────────
(ev/key= event :down)
{:model (if (= (:focus model) :messages)
(update model :scroll-offset #(max 0 (dec %)))
model)}
;; ── Page Up ────────────────────────────────────────────────────
(ev/key= event :page-up)
(let [new-model (update model :scroll-offset
#(min (+ % 10) (count (:messages model))))]
(when (and (>= (:scroll-offset new-model) (count (:messages new-model)))
(:active-channel new-model)
(seq (:messages new-model)))
(fire-load-older! ctx (:active-channel new-model)
(:id (first (:messages new-model))) queue))
{:model new-model})
;; ── Page Down ──────────────────────────────────────────────────
(ev/key= event :page-down)
{:model (update model :scroll-offset #(max 0 (- % 10)))}
;; ── Escape → focus input ───────────────────────────────────────
(ev/key= event :escape)
{:model (assoc model :focus :input)}
;; ── j/k vim navigation in messages ─────────────────────────────
(and (= (:focus model) :messages) (ev/key= event \j))
{:model (update model :scroll-offset #(max 0 (dec %)))}
(and (= (:focus model) :messages) (ev/key= event \k))
{:model (update model :scroll-offset
#(min (inc %) (count (:messages model))))}
;; ── Regular character → input ──────────────────────────────────
(and (= (:type event) :key)
(= (:focus model) :input)
(char? (:key event))
(<= 32 (int (:key event)) 126))
{:model (update model :input-text str (:key event))}
;; ── Default → ignore ───────────────────────────────────────────
:else
{:model model})))
;;; ---------------------------------------------------------------------------
;;; View functions (pure hiccup → clojure-tui render primitives)
;;; ---------------------------------------------------------------------------
(defn- view-header
"Header bar: app name | community name ... username status"
[model]
(let [comm (community-name model)
user (:username model)
[status-color status-text]
(cond
(:reconnecting model) [:yellow "reconnecting..."]
(:connected model) [:green "connected"]
:else [:red "disconnected"])]
[:row {:widths [:flex :flex]}
[:text {:bg :blue :fg :white :bold true}
(str " ajet chat | " comm " ")]
[:text {:bg :blue :fg :white}
(str user " ")
[:text {:fg status-color} status-text]
" "]]))
(defn- view-sidebar-entry
"Single channel/DM entry in sidebar."
[ch active-channel unread-counts sw]
(let [ch-id (str (:id ch))
active? (= ch-id (str active-channel))
unread (get unread-counts ch-id 0)
is-dm? (or (= (:type ch) "dm") (= (:type ch) :dm)
(= (:type ch) "group-dm") (= (:type ch) :group-dm))
prefix (if is-dm? "" "#")
raw-name (str prefix (or (:name ch) (:display-name ch) "?"))
name-str (ansi/truncate raw-name (- sw 6))]
(if active?
[:text {:bold true :fg :cyan}
"> " name-str
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])]
[:text {}
" " name-str
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])])))
(defn- view-sidebar [model]
(let [{:keys [active-channel unread-counts sidebar-width]} model
tchs (text-channels model)
dchs (dm-channels model)]
(into [:col {}
[:text {:bold true :fg :white} "CHANNELS"]]
(concat
(for [ch tchs]
(view-sidebar-entry ch active-channel unread-counts sidebar-width))
(when (seq dchs)
(cons [:text {} ""]
(cons [:text {:bold true :fg :white} "DMs"]
(for [ch dchs]
(view-sidebar-entry ch active-channel
unread-counts sidebar-width)))))))))
(defn- format-relative-time
"Simple relative time formatter."
[ts]
(if (nil? ts) ""
(try
(let [s (str ts)]
(if (> (count s) 16) (subs s 11 16) s))
(catch Exception _ ""))))
(defn- view-message
"Single message: author + timestamp, then body lines."
[msg]
;; TODO: Inline images (PRD 4.5) — show [image: file.png] placeholder only
;; TODO: Spoiler reveal (PRD 4.6) — ||spoiler|| not hidden yet
;; TODO: OSC 8 hyperlinks (PRD 4.6) — URLs not clickable
(let [author (or (:display-name msg) (:username msg) (:user-id msg) "?")
time-str (format-relative-time (or (:created-at msg) (:timestamp msg)))
body (-> (or (:body-md msg) (:body msg) "")
(mentions/render (fn [_type id] id))
markdown/->ansi)
lines (str/split-lines body)]
(into [:col {}
[:text {}
[:text {:bold true} author]
" "
[:text {:fg :bright-black} time-str]
(when (:edited-at msg)
[:text {:fg :bright-black} " (edited)"])]]
(for [line lines]
[:text {} (str " " line)]))))
(defn- view-messages
"Message list with scroll offset."
[model]
(let [{:keys [messages scroll-offset]} model
n (count messages)
visible (if (and (pos? scroll-offset) (> n 0))
(let [end (max 0 (- n scroll-offset))
start (max 0 (- end 50))]
(subvec (vec messages) start end))
messages)]
(if (seq visible)
(into [:col {}]
(for [msg visible]
(view-message msg)))
[:text {:fg :bright-black :italic true} " No messages yet"])))
(defn- view-typing [model]
(let [{:keys [typing-users]} model]
(if (seq typing-users)
[:text {:fg :bright-black :italic true}
(str " " (str/join ", " typing-users) " "
(if (= 1 (count typing-users)) "is" "are")
" typing...")]
[:text {} ""])))
;; TODO: Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
;; TODO: Multiline input (PRD 4.4) — :input is single-line, no Shift+Enter
(defn- view-input [model]
(let [ch (channel-name model)
active? (= (:focus model) :input)]
[:row {:widths [nil :flex]}
[:text (if active? {:fg :cyan} {:fg :bright-black})
(str (if active? "> " " ") "#" ch " ")]
[:input {:value (:input-text model)
:placeholder (when active? "Type a message...")}]]))
(defn- view-status-bar [model]
(let [focus-str (str "[" (name (:focus model)) "]")
keys-str "Ctrl+Q:quit Ctrl+K:search Ctrl+N/P:next/prev Tab:focus Enter:send"
err (:error-message model)]
[:text {:bg :bright-black :fg :white}
(str " " focus-str " " keys-str
(when err (str " | Error: " err)))]))
(defn- tui-view
"Root view: header / (sidebar | content) / input / status"
[model]
[:col {:heights [1 :flex 1 1]}
(view-header model)
[:row {:widths [(:sidebar-width model) :flex] :gap 1}
(view-sidebar model)
[:col {:heights [:flex 1]}
(view-messages model)
(view-typing model)]]
(view-input model)
(view-status-bar model)])
;;; ---------------------------------------------------------------------------
;;; Launch
;;; ---------------------------------------------------------------------------
(defn launch!
"Launch the TUI using clojure-tui's Elm architecture.
Options:
:community - community slug to open to
:channel - channel name to open to
Returns exit code."
[& [{:keys [community channel]}]]
(let [session (config/load-session)]
(when-not session
(throw (ex-info "Not logged in"
{:type :ajet.chat/auth-error
:hint "Run 'ajet login' to sign in"})))
(let [ctx (config/make-ctx)
queue (LinkedBlockingQueue.)
sse-running (atom true)
_ (start-sse-thread! (:base-url ctx)
(:auth-token ctx)
queue sse-running)
init-model (initial-model ctx session queue
{:community community :channel channel})
final-model (tui/run
{:init init-model
:update tui-update
:view tui-view
:init-events [{:type :load-initial-data}
(ev/delayed-event 100 {:type :sse-poll})]})]
;; Cleanup
(reset! sse-running false)
(config/save-state!
{:last-community (str (:active-community final-model))
:last-channels {(str (:active-community final-model))
(str (:active-channel final-model))}})
0)))
+41 -12
View File
@@ -1,20 +1,40 @@
{: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"]}
{;; Service deps (no :main-opts — safe to combine with :dev)
:api {:extra-deps {ajet/chat-api {:local/root "api"}}}
:web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}}
:tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}}
:cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}}
:auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}}
;; Standalone runners (use: clj -M:run/api)
:run/api {:extra-deps {ajet/chat-api {:local/root "api"}}
:main-opts ["-m" "ajet.chat.api.core"]}
:run/web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}
:main-opts ["-m" "ajet.chat.web.core"]}
:run/tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}
:main-opts ["-m" "ajet.chat.tui-sm.core"]}
:run/cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}
:main-opts ["-m" "ajet.chat.cli.core"]}
:run/auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}
:main-opts ["-m" "ajet.chat.auth-gw.core"]}
;; Development — includes all service deps + nREPL tooling
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
:extra-deps {ajet/chat-api {:local/root "api"}
ajet/chat-auth-gw {:local/root "auth-gw"}
ajet/chat-web-sm {:local/root "web-sm"}
ajet/chat-tui-sm {:local/root "tui-sm"}
nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.50.2"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}
;; Testing
:test/base {:extra-deps {ajet/chat-api {:local/root "api"}
ajet/chat-auth-gw {:local/root "auth-gw"}
ajet/chat-web-sm {:local/root "web-sm"}
ajet/chat-tui-sm {:local/root "tui-sm"}}}
:test/unit {:extra-paths ["test"]
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
:main-opts ["-m" "kaocha.runner" "--focus" "unit"]}
@@ -28,4 +48,13 @@
: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"]}}}
:main-opts ["-m" "kaocha.runner"]}
:test {:extra-paths ["test"]
:extra-deps {ajet/chat-api {:local/root "api"}
ajet/chat-auth-gw {:local/root "auth-gw"}
ajet/chat-web-sm {:local/root "web-sm"}
ajet/chat-tui-sm {:local/root "tui-sm"}
lambdaisland/kaocha {:mvn/version "1.91.1392"}
org.babashka/http-client {:mvn/version "0.4.22"}}}
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"}}
:ns-default build}}}
+71
View File
@@ -0,0 +1,71 @@
(ns user
"REPL development helpers. Load all services and provide start!/stop!/reset!."
(:refer-clojure :exclude [reset!])
(:require [clojure.tools.logging :as log]))
;; Lazy-load service namespaces to avoid compile errors when not all deps are on classpath
(defn- require-ns [sym]
(try
(require sym)
true
(catch Exception _
false)))
(defn start!
"Start all services that are on the classpath."
[]
(log/info "Starting development services...")
(when (require-ns 'ajet.chat.api.core)
((resolve 'ajet.chat.api.core/start!))
(log/info "API service started"))
(when (require-ns 'ajet.chat.auth-gw.core)
((resolve 'ajet.chat.auth-gw.core/start!))
(log/info "Auth Gateway started"))
(when (require-ns 'ajet.chat.web.core)
((resolve 'ajet.chat.web.core/start!))
(log/info "Web SM started"))
(when (require-ns 'ajet.chat.tui-sm.core)
((resolve 'ajet.chat.tui-sm.core/start!))
(log/info "TUI SM started"))
(log/info "All services started."))
(defn stop!
"Stop all running services."
[]
(log/info "Stopping development services...")
(when (find-ns 'ajet.chat.tui-sm.core)
((resolve 'ajet.chat.tui-sm.core/stop!)))
(when (find-ns 'ajet.chat.web.core)
((resolve 'ajet.chat.web.core/stop!)))
(when (find-ns 'ajet.chat.auth-gw.core)
((resolve 'ajet.chat.auth-gw.core/stop!)))
(when (find-ns 'ajet.chat.api.core)
((resolve 'ajet.chat.api.core/stop!)))
(log/info "All services stopped."))
(defn reset!
"Stop, reload, and restart all services."
[]
(stop!)
(start!))
;; Auto-start all services when the REPL loads
(try
(start!)
(catch Exception e
(log/error e "Failed to auto-start services — REPL is still available. Fix the issue and run (start!)")
(println "\n*** AUTO-START FAILED ***")
(println "Error:" (.getMessage e))
(println "REPL is ready — fix the issue and run (start!) manually.\n")))
(comment
;; After code changes:
(reset!)
;; Stop services:
(stop!)
;; Start individual services:
(require 'ajet.chat.api.core)
(ajet.chat.api.core/start!)
(ajet.chat.api.core/stop!)
)
+64
View File
@@ -0,0 +1,64 @@
# docker-compose.dev.yml — infrastructure only, Clojure services run via REPL
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: ajet_chat
POSTGRES_USER: ajet
POSTGRES_PASSWORD: ajet_dev
volumes:
- pgdata_dev:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
interval: 5s
timeout: 3s
retries: 5
nats:
image: nats:2.10-alpine
ports:
- "4222:4222"
- "8222:8222"
command: ["--js", "--sd", "/data", "-m", "8222"]
volumes:
- natsdata_dev:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- miniodata_dev:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 3s
retries: 5
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing local/ajet-chat;
"
volumes:
pgdata_dev:
natsdata_dev:
miniodata_dev:
+281
View File
@@ -0,0 +1,281 @@
# docker-compose.test.yml — ephemeral infra for testing
#
# Integration tests (infra only):
# docker compose -f docker-compose.test.yml --profile init up -d
#
# E2E tests (full stack + browser):
# docker compose -f docker-compose.test.yml --profile e2e up -d --build
# cd e2e && npx nbb -cp src -m ajet-chat.e2e.runner
services:
postgres-test:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
POSTGRES_DB: ajet_chat_test
POSTGRES_USER: ajet
POSTGRES_PASSWORD: ajet_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat_test"]
interval: 3s
timeout: 2s
retries: 10
nats-test:
image: nats:2.10-alpine
ports:
- "4223:4222"
command: ["--js", "-m", "8222"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
interval: 3s
timeout: 2s
retries: 10
minio-test:
image: minio/minio:latest
ports:
- "9002:9000"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data
tmpfs:
- /data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 3s
timeout: 2s
retries: 10
minio-test-init:
profiles: ["init"]
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio-test:9000 minioadmin minioadmin;
mc mb --ignore-existing local/ajet-chat;
"
# --------------------------------------------------------------------------
# Gitea — self-contained OAuth provider for E2E tests
# --------------------------------------------------------------------------
gitea-test:
profiles: ["e2e"]
image: gitea/gitea:1.22
ports:
- "4080:3000"
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__security__INSTALL_LOCK=true
- GITEA__server__ROOT_URL=http://localhost:4080
- GITEA__server__HTTP_PORT=3000
- GITEA__service__DISABLE_REGISTRATION=false
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
- GITEA__oauth2__ENABLE=true
tmpfs:
- /data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/version"]
interval: 3s
timeout: 2s
retries: 20
start_period: 10s
gitea-test-init:
profiles: ["e2e"]
image: curlimages/curl:latest
depends_on:
gitea-test:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
set -e
GITEA=http://gitea-test:3000
echo "==> Creating Gitea admin user..."
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
-H "Content-Type: application/json" \
-u "gitea-admin:gitea-admin-pass" \
-d '{"username":"gitea-admin","password":"gitea-admin-pass","email":"admin@test.local","must_change_password":false,"login_name":"gitea-admin","source_id":0,"visibility":"public"}' \
|| true
# Gitea with INSTALL_LOCK=true uses the first registered user as admin.
# Verify admin access, fall back to sign-up if needed:
curl -sf -X POST "$$GITEA/api/v1/user/repos" \
-H "Content-Type: application/json" \
-u "gitea-admin:gitea-admin-pass" \
-d '{"name":"test"}' \
> /dev/null 2>&1 || {
echo "==> Admin doesn't exist yet, creating via sign-up..."
curl -sf -X POST "$$GITEA/user/sign_up" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "user_name=gitea-admin&password=gitea-admin-pass&retype=gitea-admin-pass&email=admin@test.local" \
|| true
sleep 2
}
echo "==> Getting admin token..."
TOKEN=$$(curl -sf -X POST "$$GITEA/api/v1/users/gitea-admin/tokens" \
-u "gitea-admin:gitea-admin-pass" \
-H "Content-Type: application/json" \
-d '{"name":"e2e-setup","scopes":["all"]}' | sed 's/.*"sha1":"//' | sed 's/".*//')
echo "Token: $$TOKEN"
echo "==> Creating test user A..."
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
-H "Content-Type: application/json" \
-H "Authorization: token $$TOKEN" \
-d '{"username":"testuser-a","password":"testpass-a","email":"testuser-a@test.local","must_change_password":false}' \
|| true
echo "==> Creating test user B..."
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
-H "Content-Type: application/json" \
-H "Authorization: token $$TOKEN" \
-d '{"username":"testuser-b","password":"testpass-b","email":"testuser-b@test.local","must_change_password":false}' \
|| true
echo "==> Gitea init complete! Users: gitea-admin, testuser-a, testuser-b"
echo "==> OAuth app will be created by the nbb test runner via Gitea API"
# --------------------------------------------------------------------------
# E2E profile — application service containers
# --------------------------------------------------------------------------
auth-gw:
profiles: ["e2e"]
build:
context: .
dockerfile: auth-gw/Dockerfile
ports:
- "4000:3000"
environment:
AJET__DB__HOST: postgres-test
AJET__DB__PORT: 5432
AJET__DB__DBNAME: ajet_chat_test
AJET__DB__USER: ajet
AJET__DB__PASSWORD: ajet_test
AJET__SERVICES__API__HOST: api
AJET__SERVICES__WEB_SM__HOST: web-sm
AJET__SERVICES__TUI_SM__HOST: tui-sm
AJET__SESSION__COOKIE_SECURE: "false"
AJET__OAUTH__GITEA_SERVER_BASE_URL: "http://host.docker.internal:4080"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres-test:
condition: service_healthy
api:
condition: service_healthy
web-sm:
condition: service_healthy
tui-sm:
condition: service_healthy
gitea-test-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 5s
timeout: 3s
retries: 15
start_period: 20s
api:
profiles: ["e2e"]
build:
context: .
dockerfile: api/Dockerfile
ports:
- "4001:3001"
environment:
AJET__DB__HOST: postgres-test
AJET__DB__PORT: 5432
AJET__DB__DBNAME: ajet_chat_test
AJET__DB__USER: ajet
AJET__DB__PASSWORD: ajet_test
AJET__NATS__URL: nats://nats-test:4222
AJET__MINIO__ENDPOINT: http://minio-test:9000
AJET__MINIO__ACCESS_KEY: minioadmin
AJET__MINIO__SECRET_KEY: minioadmin
depends_on:
postgres-test:
condition: service_healthy
nats-test:
condition: service_healthy
minio-test:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 5s
timeout: 3s
retries: 15
start_period: 20s
web-sm:
profiles: ["e2e"]
build:
context: .
dockerfile: web-sm/Dockerfile
ports:
- "4002:3002"
environment:
AJET__API__BASE_URL: http://api:3001
AJET__NATS__URL: nats://nats-test:4222
depends_on:
nats-test:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3002/web/health"]
interval: 5s
timeout: 3s
retries: 15
start_period: 20s
tui-sm:
profiles: ["e2e"]
build:
context: .
dockerfile: tui-sm/Dockerfile
ports:
- "4003:3003"
environment:
AJET__API__BASE_URL: http://api:3001
AJET__NATS__URL: nats://nats-test:4222
depends_on:
nats-test:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3003/tui/health"]
interval: 5s
timeout: 3s
retries: 15
start_period: 20s
test-runner:
profiles: ["e2e-docker"]
build:
context: .
dockerfile: e2e/Dockerfile
environment:
AJET_TEST_BASE_URL: http://auth-gw:3000
AJET_TEST_DB_HOST: postgres-test
AJET_TEST_DB_PORT: 5432
AJET_TEST_DB_DBNAME: ajet_chat_test
AJET_TEST_DB_USER: ajet
AJET_TEST_DB_PASSWORD: ajet_test
AJET_TEST_NATS_URL: nats://nats-test:4222
AJET_TEST_MINIO_ENDPOINT: http://minio-test:9000
AJET_TEST_MINIO_ACCESS_KEY: minioadmin
AJET_TEST_MINIO_SECRET_KEY: minioadmin
depends_on:
auth-gw:
condition: service_healthy
+121
View File
@@ -0,0 +1,121 @@
# docker-compose.yml — production full stack with nginx TLS termination
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
- auth-gw
restart: unless-stopped
auth-gw:
build:
context: .
dockerfile: auth-gw/Dockerfile
environment:
AJET__DB__HOST: postgres
AJET__DB__PASSWORD: ${AJET__DB__PASSWORD}
AJET__OAUTH__GITHUB__CLIENT_ID: ${GITHUB_CLIENT_ID}
AJET__OAUTH__GITHUB__CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
AJET__SERVICES__API__HOST: api
AJET__SERVICES__WEB_SM__HOST: web-sm
AJET__SERVICES__TUI_SM__HOST: tui-sm
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
api:
build:
context: .
dockerfile: api/Dockerfile
environment:
AJET__DB__HOST: postgres
AJET__DB__PASSWORD: ${AJET__DB__PASSWORD}
AJET__NATS__URL: nats://nats:4222
AJET__MINIO__ENDPOINT: http://minio:9000
AJET__MINIO__ACCESS_KEY: ${MINIO_ACCESS_KEY}
AJET__MINIO__SECRET_KEY: ${MINIO_SECRET_KEY}
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
web-sm:
build:
context: .
dockerfile: web-sm/Dockerfile
environment:
AJET__API__BASE_URL: http://api:3001
AJET__NATS__URL: nats://nats:4222
depends_on:
nats:
condition: service_healthy
restart: unless-stopped
tui-sm:
build:
context: .
dockerfile: tui-sm/Dockerfile
environment:
AJET__API__BASE_URL: http://api:3001
AJET__NATS__URL: nats://nats:4222
depends_on:
nats:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ajet_chat
POSTGRES_USER: ajet
POSTGRES_PASSWORD: ${AJET__DB__PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
nats:
image: nats:2.10-alpine
command: ["--js", "--sd", "/data", "-m", "8222"]
volumes:
- natsdata:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
minio:
image: minio/minio:latest
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server /data
volumes:
- miniodata:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
natsdata:
miniodata:
+13 -8
View File
@@ -6,15 +6,16 @@ Product Requirements Documents for ajet-chat v1.
| Module | PRD | Test Cases | Status |
|--------|-----|------------|--------|
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown | 60 | v1 |
| [api](api.md) | REST API: 15 endpoint groups, full CRUD | 95 | v1 |
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy | 40 | v1 |
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout | 57 | v1 |
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events | 30 | v1 |
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui) | 50 | v1 |
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown, Config, Logging, Storage | 92 | v1 |
| [api](api.md) | REST API: 15 endpoint groups, full CRUD, health check, ban/mute | 129 | v1 |
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy, CORS, audit logging | 40 | v1 |
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout, emoji picker, toasts | 57 | v1 |
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events, backpressure | 30 | v1 |
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui), bbin packaging, exit codes | 79 | v1 |
| [infrastructure](infrastructure.md) | Docker Compose (dev/test/prod), NATS JetStream, MinIO, nginx | 10 | v1 |
| [mobile](mobile.md) | Deferred — PWA recommended for v1 | 0 | v2+ |
**Total test cases: ~332**
**Total test cases: 437**
## Key Product Decisions
@@ -26,7 +27,9 @@ Product Requirements Documents for ajet-chat v1.
- **Mention storage** — `@<user:uuid>` / `@<here>` / `#<channel:uuid>` in DB, rendered at display time
- **1-hour edit window** — messages can only be edited within 1 hour of creation
- **Images + paste** — clipboard paste and upload, no arbitrary file types in v1
- **OAuth-only auth** — GitHub + Gitea + generic OIDC (for self-hosters)
- **OAuth auth** — GitHub + Gitea + generic OIDC (future: email-based auth via magic link/OTP)
- **DB-stored OAuth providers** — OAuth provider config stored in `oauth_providers` table, manageable at runtime via admin API
- **Admin setup wizard** — multi-step first-deployment bootstrap: configure providers, admin OAuth login, create first community
- **3-tier roles** — Owner / Admin / Member (no custom roles in v1)
- **Invite links + direct invites** — admins generate links or invite by user ID
- **Incoming webhooks** — external services POST to channel (outgoing deferred)
@@ -36,3 +39,5 @@ Product Requirements Documents for ajet-chat v1.
- **Paginated messages** — "Load older" button, cursor-based pagination
- **Rich TUI** — split panes, inline images (timg/sixel), markdown rendering, mouse support
- **Full CLI** — all operations scriptable, JSON output, stdin piping
- **Tailwind CSS** — dark theme, utility-first styling in Hiccup
- **Ban/mute enforcement** — bans permanent until lifted, mutes time-limited with auto-expiry
+186
View File
@@ -47,6 +47,10 @@ Auth Gateway → API Service → PostgreSQL
016-create-webhooks.up.sql
017-create-invites.up.sql
018-add-search-indexes.up.sql
019-create-bans.up.sql
020-create-mutes.up.sql
021-create-oauth-providers.up.sql
022-create-system-settings.up.sql
```
### 3.2 Tables
@@ -75,8 +79,41 @@ idx_messages_search ON messages USING GIN(to_tsvector('english', body_md))
idx_notifications_user_unread ON notifications(user_id, created_at) WHERE read = false
idx_channel_members_user ON channel_members(user_id)
idx_community_members_user ON community_members(user_id)
-- Ban/mute enforcement
bans (community_id uuid FK, user_id uuid FK, reason text, banned_by uuid FK, created_at timestamptz, PK(community_id, user_id))
mutes (community_id uuid FK, user_id uuid FK, expires_at timestamptz, muted_by uuid FK, created_at timestamptz, PK(community_id, user_id))
idx_mutes_expires ON mutes(expires_at) WHERE expires_at IS NOT NULL
-- OAuth providers (runtime-configurable)
oauth_providers (id uuid PK, provider_type text CHECK(github/gitea/oidc), name text, client_id text, client_secret_encrypted text, base_url text NULL, issuer_url text NULL, enabled boolean DEFAULT true, created_at timestamptz, updated_at timestamptz)
idx_oauth_providers_type ON oauth_providers(provider_type)
-- System settings (key-value for deployment-wide config)
system_settings (key text PK, value jsonb, updated_at timestamptz)
```
### 3.3 Ban & Mute Enforcement
**Bans:**
- Ban record in `bans` table prevents user from:
- Sending messages in any channel of the community
- Joining channels
- Accepting invites to the community
- Banned user is removed from all channels and community membership on ban
- Ban check runs in middleware for all community-scoped API endpoints
- Bans are permanent until explicitly lifted by Admin+
**Mutes:**
- Mute record in `mutes` table with `expires_at` timestamp
- Muted user cannot:
- Send messages (POST to message endpoints returns 403)
- Add reactions
- Send typing indicators
- Muted user CAN still read messages and channels
- Expired mutes are ignored (no cleanup needed — checked on read)
- Duration specified as interval: `10m`, `1h`, `24h`, `7d`
## 4. API Endpoints
### 4.1 Communities
@@ -590,6 +627,43 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
---
### 4.16 Admin: OAuth Provider Management
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/admin/oauth-providers` | List all OAuth providers | Owner |
| POST | `/api/admin/oauth-providers` | Create OAuth provider | Owner |
| PUT | `/api/admin/oauth-providers/:id` | Update OAuth provider | Owner |
| DELETE | `/api/admin/oauth-providers/:id` | Delete OAuth provider | Owner |
**POST /api/admin/oauth-providers**
```
Request: {"provider_type": "github", "name": "GitHub", "client_id": "...", "client_secret": "...", "enabled": true}
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub", "client_id": "...", "enabled": true, "created_at": "..."}
Note: client_secret is encrypted at rest and never returned in responses.
```
**PUT /api/admin/oauth-providers/:id**
```
Request: {"name": "GitHub Org", "client_id": "...", "client_secret": "...", "enabled": false}
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub Org", ...}
Note: Omitting client_secret from the request leaves it unchanged.
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| AOP-T1 | Create OAuth provider | POST creates provider, returns without client_secret |
| AOP-T2 | List providers | GET returns all providers without secrets |
| AOP-T3 | Update provider | PUT updates name/enabled, secret unchanged if omitted |
| AOP-T4 | Delete provider | DELETE removes provider |
| AOP-T5 | Non-owner access | Non-owner user returns 403 |
| AOP-T6 | Invalid provider_type | POST with unknown type returns 422 |
| AOP-T7 | Duplicate provider_type | Allowed (multiple GitHub providers for different orgs) |
---
## 5. Cross-Cutting Concerns
### 5.1 Error Response Format
@@ -609,3 +683,115 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
### 5.4 Audit Trail (P2)
- Log all admin actions (kick, ban, role change, channel delete) with actor + target + timestamp
- Queryable by Owner via future admin API
---
## 6. Service Configuration
### 6.1 Config Shape
```clojure
{:server {:host "0.0.0.0" :port 3001}
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
:user "ajet" :password "..." :pool-size 10
:migrations {:enabled true :location "migrations"}}
:nats {:url "nats://localhost:4222"
:stream-name "ajet-events"
:publish-timeout-ms 5000}
:minio {:endpoint "http://localhost:9000"
:access-key "minioadmin" :secret-key "minioadmin"
:bucket "ajet-chat"}
:limits {:max-message-length 4000 ;; characters
:max-upload-size 10485760 ;; 10MB in bytes
:edit-window-minutes 60
:default-page-size 50
:max-page-size 100}}
```
### 6.2 Middleware Pipeline
Requests flow through middleware in this order:
```
1. Ring defaults (params, cookies, multipart)
2. Request logging (method, path, start time)
3. Exception handler (catch-all → 500 JSON error)
4. Trace ID extraction (X-Trace-Id → MDC)
5. User context extraction (X-User-Id, X-User-Role, X-Community-Id → request map)
6. Ban check (community-scoped: reject if user is banned)
7. Mute check (write endpoints: reject if user is muted)
8. Reitit routing → handler
9. Response logging (status, duration)
```
### 6.3 Startup / Shutdown Sequence
**Startup:**
```
1. Load config (EDN + env vars)
2. Create DB connection pool (HikariCP)
3. Run Migratus migrations (if enabled)
4. Connect to NATS
5. Connect to MinIO, ensure bucket exists
6. Start http-kit server
7. Log "API service started on port {port}"
```
**Shutdown (graceful):**
```
1. Stop accepting new HTTP connections
2. Wait for in-flight requests (max 30s)
3. Close NATS connection
4. Close DB connection pool
5. Log "API service stopped"
```
### 6.4 Health Check
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/health` | None | Service health status |
**Response (200):**
```json
{"status": "ok", "checks": {"db": "ok", "nats": "ok", "minio": "ok"}}
```
**Response (503 — degraded):**
```json
{"status": "degraded", "checks": {"db": "ok", "nats": "error", "minio": "ok"}}
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| HLT-T1 | Health check all up | Returns 200 with all checks "ok" |
| HLT-T2 | Health check DB down | Returns 503 with db check "error" |
| HLT-T3 | Health check NATS down | Returns 503 with nats check "error" |
| HLT-T4 | Health check MinIO down | Returns 503 with minio check "error" |
---
## 7. Migration SQL (Outlines)
### 7.1 Migration Naming Convention
```
{NNN}-{description}.up.sql — forward migration
{NNN}-{description}.down.sql — rollback migration
```
Migrations are sequential and must be applied in order. Each migration is idempotent — re-running a completed migration is a no-op (handled by Migratus tracking table).
### 7.2 Key Migration Notes
- **001-create-users:** `users` table + unique index on `username` + unique index on `email`
- **006-create-channels:** `channels` table, `community_id` is nullable (DMs have NULL)
- **008-create-messages:** `messages` table + composite index `(channel_id, created_at)` for pagination
- **010-create-reactions:** Composite PK `(message_id, user_id, emoji)` — one reaction per user per emoji per message
- **018-add-search-indexes:** `to_tsvector('english', body_md)` GIN index on `messages` for full-text search
- **019-create-bans:** `bans` table with composite PK `(community_id, user_id)`
- **020-create-mutes:** `mutes` table with composite PK `(community_id, user_id)`, index on `expires_at`
- **021-create-oauth-providers:** `oauth_providers` table for runtime-configurable OAuth providers
- **022-create-system-settings:** `system_settings` key-value table for deployment-wide settings (e.g., `setup_completed`)
+181 -31
View File
@@ -31,9 +31,13 @@ Client → (nginx TLS, prod) → Auth Gateway → API Service
| `GET /tui/sse/*` | TUI SM | Session | SSE streams for TUI clients |
| `POST /tui/*` | TUI SM | Session | TUI client signals |
| `POST /api/webhooks/*/incoming` | API | Webhook Token | Incoming webhooks (bypass session auth) |
| `GET /auth/login` | Self | None | Login page |
| `GET /auth/login` | Self | None | Login page (OAuth provider buttons) |
| `GET /auth/callback/:provider` | Self | None | OAuth callback |
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
| `GET /setup/providers` | Self | None | Setup wizard step 1: configure OAuth providers |
| `POST /setup/providers` | Self | None | Setup wizard step 1: save OAuth provider config |
| `GET /setup/community` | Self | Session | Setup wizard step 3: create first community |
| `POST /setup/community` | Self | Session | Setup wizard step 3: submit community creation |
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
| `GET /health` | Self | None | Health check |
@@ -75,20 +79,23 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
**Supported providers:** GitHub, Gitea, Generic OIDC
**Provider storage:** OAuth providers are stored in the `oauth_providers` DB table and are configurable at runtime via admin endpoints (`/api/admin/oauth-providers`). On first startup, if the `oauth_providers` table is empty, any providers configured via env vars (`:oauth` config) are auto-migrated to the DB.
```
1. User visits /auth/login
2. Page shows provider buttons (GitHub, Gitea, or configured OIDC)
3. User clicks provider → redirect to provider's authorize URL
4. Provider redirects to /auth/callback/:provider with code
5. Auth GW exchanges code for access token
6. Auth GW fetches user profile from provider
7. Look up oauth_accounts by (provider, provider_user_id):
2. Auth GW loads enabled providers from oauth_providers table
3. Page shows provider buttons for each enabled provider
4. User clicks provider redirect to provider's authorize URL
5. Provider redirects to /auth/callback/:provider with code
6. Auth GW exchanges code for access token
7. Auth GW fetches user profile from provider
8. Look up oauth_accounts by (provider, provider_user_id):
a. EXISTS: load user, create session
b. NOT EXISTS: create user + oauth_account, create session
8. Set session cookie, redirect to / (or to pending invite if present)
9. Set session cookie, redirect to / (or to pending invite if present)
```
**OAuth config shape:**
**OAuth config shape (fallback — auto-migrated to DB on first startup):**
```clojure
{:oauth
{:github {:client-id "..." :client-secret "..." :enabled true}
@@ -96,23 +103,39 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
```
After migration, provider config is read exclusively from the DB. The `:oauth` config key serves only as a seed for initial deployment and is ignored once providers exist in the DB.
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
### 4.4 First-User Bootstrap
### 4.4 Admin Setup Wizard (First-Deployment Bootstrap)
The setup wizard is a multi-step flow handled by Auth GW for first-time deployment. It activates when the `system_settings` table indicates setup is incomplete (no `setup_completed` flag).
```
1. User hits /auth/login
2. Auth GW checks: any users in DB?
- No users: show "Create your community" flow after OAuth
- Has users: normal login
3. After first OAuth login:
a. Create user from OAuth profile
b. Redirect to /setup (community creation wizard on Web SM)
c. Web SM shows: community name input, slug auto-generated
d. POST creates community (user becomes owner, #general created)
e. Redirect to /app
Step 1 — Configure OAuth Providers (no auth required):
1. User hits any route on a fresh deployment
2. Auth GW checks system_settings: setup_completed?
- Not completed: redirect to /setup/providers
- Completed: normal login flow
3. /setup/providers shows a form to configure at least one OAuth provider
(provider type, client ID, client secret, base URL / issuer URL)
4. Admin submits provider config → saved to oauth_providers table
5. Auth GW redirects to /auth/login with the newly configured provider(s)
Step 2 — Admin Login via OAuth:
6. Admin logs in via one of the just-configured OAuth providers
7. First user is created with admin/owner privileges
Step 3 — Create First Community:
8. After login, redirect to /setup/community (rendered by Auth GW, not Web SM)
9. Admin enters community name, slug auto-generated
10. POST creates community (user becomes owner, #general created)
11. system_settings.setup_completed = true
12. Redirect to /app
```
Subsequent community creation (by already-authenticated users) uses the Web SM `/setup` page.
### 4.5 Invite Flow
```
@@ -149,6 +172,7 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
| Endpoint Pattern | Limit | Window |
|-----------------|-------|--------|
| `POST /auth/login` | 10 | 1 min per IP |
| `POST /auth/callback/*` | 10 | 1 min per IP |
| `POST /api/*` | 60 | 1 min per user |
| `GET /api/*` | 120 | 1 min per user |
@@ -205,13 +229,18 @@ Attributes:
| AUTH-T17 | New user first login | Creates user + oauth_account + session |
| AUTH-T18 | OAuth state parameter | CSRF protection via state param validated on callback |
### 8.4 First-User Bootstrap
### 8.4 Admin Setup Wizard
| ID | Test | Description |
|----|------|-------------|
| AUTH-T19 | Empty DB shows setup flow | /auth/login with 0 users shows setup message |
| AUTH-T20 | First user becomes owner | After OAuth + community creation, user has owner role |
| AUTH-T21 | Subsequent users see normal login | With users in DB, normal login page shown |
| AUTH-T19 | Fresh deploy redirects to setup | Any route with setup_completed=false redirects to /setup/providers |
| AUTH-T20 | Step 1: configure OAuth provider | POST /setup/providers saves provider to oauth_providers table |
| AUTH-T21 | Step 1: requires at least one provider | POST /setup/providers with empty config returns 422 |
| AUTH-T22a | Step 2: login via configured provider | After provider setup, /auth/login shows newly configured provider |
| AUTH-T22b | Step 3: first user becomes owner | After OAuth + community creation, user has owner role |
| AUTH-T22c | Setup completed flag set | After community creation, system_settings.setup_completed = true |
| AUTH-T22d | Subsequent users see normal login | With setup_completed=true, normal login page shown |
| AUTH-T22e | Setup routes blocked after completion | /setup/providers returns 403 when setup_completed=true |
### 8.5 Invite Flow
@@ -222,7 +251,7 @@ Attributes:
| AUTH-T24 | Exhausted invite | Shows error page |
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
### 8.6 Reverse Proxy
### 8.7 Reverse Proxy
| ID | Test | Description |
|----|------|-------------|
@@ -234,7 +263,7 @@ Attributes:
| AUTH-T31 | Headers injected | X-User-Id, X-Trace-Id present on proxied request |
| AUTH-T32 | Original auth headers stripped | Client cannot forge X-User-Id |
### 8.7 Rate Limiting
### 8.8 Rate Limiting
| ID | Test | Description |
|----|------|-------------|
@@ -243,14 +272,14 @@ Attributes:
| AUTH-T35 | Rate limit per-user | Different users have independent limits |
| AUTH-T36 | Rate limit per-IP for auth | OAuth callback rate limited by IP |
### 8.8 Logout
### 8.9 Logout
| ID | Test | Description |
|----|------|-------------|
| AUTH-T37 | Logout destroys session | POST /auth/logout deletes session from DB, clears cookie |
| AUTH-T38 | Logout with invalid session | Returns 200 (idempotent), clears cookie |
### 8.9 Health Check
### 8.10 Health Check
| ID | Test | Description |
|----|------|-------------|
@@ -259,7 +288,113 @@ Attributes:
---
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
## 9. Service Configuration
### 9.1 Config Shape
```clojure
{:server {:host "0.0.0.0" :port 3000}
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
:user "ajet" :password "..." :pool-size 5}
:oauth {:github {:client-id "..." :client-secret "..." :enabled true}
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled false}
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}
;; ↑ Fallback seed only — auto-migrated to oauth_providers DB table on first startup.
;; Ignored once providers exist in DB. Manage providers via admin API after setup.
:services {:api {:host "localhost" :port 3001}
:web-sm {:host "localhost" :port 3002}
:tui-sm {:host "localhost" :port 3003}}
:session {:ttl-days 30
:cookie-name "ajet_session"
:cookie-secure true} ;; false in dev
:rate-limit {:enabled true}
:cors {:allowed-origins ["https://chat.example.com"]
:allowed-methods [:get :post :put :delete :options]
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
:max-age 86400}}
```
### 9.2 CORS Configuration
CORS headers applied to all responses:
```
Access-Control-Allow-Origin: <configured origin or request Origin if in allowed list>
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Trace-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
```
- **Dev mode:** Allow `http://localhost:*` origins
- **Prod mode:** Strict origin whitelist from config
- **Preflight requests:** `OPTIONS` handled and returned immediately (no proxy)
### 9.3 Audit Logging
**What's logged:**
- All admin actions: kick, ban, mute, role change, channel delete, webhook create/delete, invite create/revoke
- Authentication events: login success, login failure, logout, session expiry
- Rate limit violations
**Audit log table (in PostgreSQL, written by Auth GW):**
```sql
audit_log (
id uuid PK,
actor_id uuid FKusers NULL, -- NULL for unauthenticated events
action text, -- 'login', 'kick', 'ban', 'channel.delete', etc.
target_type text NULL, -- 'user', 'channel', 'community', etc.
target_id uuid NULL,
community_id uuid NULL,
ip_address inet,
metadata jsonb, -- extra context (reason, duration, etc.)
created_at timestamptz
)
idx_audit_log_actor ON audit_log(actor_id, created_at)
idx_audit_log_community ON audit_log(community_id, created_at)
```
**Note:** Auth GW writes audit logs directly to PG (it has DB access). API sends audit-worthy events to Auth GW via NATS subject `chat.audit` — Auth GW subscribes and persists them.
### 9.4 Middleware Pipeline
```
1. CORS headers (preflight short-circuit)
2. Request ID generation (X-Trace-Id if not present)
3. Request logging
4. Rate limiting (per-IP for auth, per-user for API)
5. Route matching
6. Auth endpoints → handle directly (OAuth, login page, logout)
7. Health check → handle directly
8. Webhook endpoints → webhook token validation → proxy to API
9. All other → session/token validation → header injection → proxy to target
10. Response logging (status, duration)
```
### 9.5 Startup / Shutdown Sequence
**Startup:**
```
1. Load config (EDN + env vars)
2. Create DB connection pool (HikariCP)
3. Auto-migrate OAuth providers from :oauth config to oauth_providers table (if table is empty)
4. Load enabled OAuth providers from DB
5. Initialize rate limiter (in-memory atom)
6. Start http-kit server
7. Log "Auth Gateway started on port {port}"
```
**Shutdown (graceful):**
```
1. Stop accepting new connections
2. Wait for in-flight requests (max 30s)
3. Close DB connection pool
4. Log "Auth Gateway stopped"
```
---
## 10. Login Page UI Mock (Hiccup rendered by Auth GW)
```
┌──────────────────────────────────────┐
@@ -271,13 +406,13 @@ Attributes:
│ Sign in to continue │
│ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with GitHub │ │
│ │ ◉ Continue with GitHub │ │ ← providers loaded from DB
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with Gitea │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with SSO │ │ ← only if OIDC configured
│ │ ◉ Continue with SSO │ │ ← only if OIDC provider in DB
│ └──────────────────────────┘ │
│ │
│ ─── or accepting invite ─── │ ← only if invite code present
@@ -285,3 +420,18 @@ Attributes:
│ │
└──────────────────────────────────────┘
```
---
## 11. Error Pages
Auth GW renders simple HTML error pages for:
| Status | Page | When |
|--------|------|------|
| 401 | Unauthorized | Invalid/expired session (web requests redirect to `/auth/login` instead) |
| 403 | Forbidden | Valid session but insufficient permission |
| 404 | Not Found | Unknown route |
| 429 | Rate Limited | Too many requests — shows retry countdown |
| 502 | Bad Gateway | Target service unreachable |
| 503 | Service Unavailable | Auth GW degraded (DB down) |
+100 -9
View File
@@ -310,9 +310,100 @@ Last-Event-ID: <id> (for reconnection)
- Unread counts in sidebar (same as web)
- Notification list accessible via slash command `/notifications`
## 5. Test Cases
## 5. Distribution & Packaging
### 5.1 CLI Authentication
### 5.1 bbin Packaging
**Build:** Compile to an uberjar, then distribute via bbin (Babashka binary installer).
```bash
# Build uberjar
clj -T:build uber # produces target/ajet-chat-cli.jar
# Install locally via bbin
bbin install . --as ajet # installs 'ajet' command from local project
# Install from remote (for users)
bbin install io.github.ajet/ajet-chat-cli --as ajet
```
**Binary:** The `ajet` command is a shell wrapper that invokes `java -jar` (or `bb` if Babashka-compatible). First run may be slow due to JVM startup; subsequent runs benefit from Drip or CDS caching.
### 5.2 Babashka Compatibility
**Goal:** CLI mode should be Babashka-compatible for fast startup. TUI mode requires JVM (clojure-tui dependency).
**Constraints for Babashka compatibility:**
- No `deftype` / `defrecord` (use maps + protocols sparingly)
- No `gen-class`
- Use `babashka.http-client` (not `clj-http`)
- Use `clojure.data.json` (bb-compatible)
- Avoid Java interop beyond what bb supports
- All CLI commands (non-TUI) target < 100ms startup via bb
**Fallback:** If Babashka compatibility proves too restrictive, ship as JVM uberjar with CDS (Class Data Sharing) for faster startup.
### 5.3 Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error (API error, unexpected failure) |
| 2 | Usage error (bad arguments, unknown command) |
| 3 | Authentication error (not logged in, token expired) |
| 4 | Permission error (403 from API) |
| 5 | Not found (404 from API — channel, message, user doesn't exist) |
| 130 | Interrupted (Ctrl+C / SIGINT) |
### 5.4 Error Message UX
All errors follow this format:
```
Error: <short description>
<details or suggestion>
Hint: <actionable next step>
```
**Examples:**
```
Error: Not logged in
No session token found. You need to authenticate first.
Hint: Run 'ajet login' to sign in
```
```
Error: Channel not found: #nonexistent
The channel doesn't exist or you don't have access.
Hint: Run 'ajet channels' to see available channels
```
```
Error: Edit window expired
Messages can only be edited within 1 hour of creation.
This message was sent 3 hours ago.
```
### 5.5 Offline Behavior
| Scenario | Behavior |
|----------|----------|
| Server unreachable | `Error: Cannot connect to server at chat.example.com` + hint to check config |
| Timeout (> 10s) | `Error: Request timed out` + hint to retry |
| TUI SSE disconnects | Status bar shows "Reconnecting..." + auto-retry with backoff |
| TUI SSE reconnects | Catches up on missed events, no user action needed |
---
## 6. Test Cases
### 6.1 CLI Authentication
| ID | Test | Description |
|----|------|-------------|
@@ -323,7 +414,7 @@ Last-Event-ID: <id> (for reconnection)
| CLI-T5 | Expired token | Commands return clear "session expired, run ajet login" message |
| CLI-T6 | No config exists | First run creates config dir and prompts for server URL |
### 5.2 CLI Commands
### 6.2 CLI Commands
| ID | Test | Description |
|----|------|-------------|
@@ -351,7 +442,7 @@ Last-Event-ID: <id> (for reconnection)
| CLI-T28 | Unknown command | Prints help with suggestion |
| CLI-T29 | No arguments | Prints usage/help |
### 5.3 TUI Launch & Layout
### 6.3 TUI Launch & Layout
| ID | Test | Description |
|----|------|-------------|
@@ -362,7 +453,7 @@ Last-Event-ID: <id> (for reconnection)
| TUI-T5 | Online indicators | Online users have green dot in DM list |
| TUI-T6 | Status bar | Shows keybindings and connection status |
### 5.4 TUI Navigation
### 6.4 TUI Navigation
| ID | Test | Description |
|----|------|-------------|
@@ -377,7 +468,7 @@ Last-Event-ID: <id> (for reconnection)
| TUI-T15 | Esc closes panels | Thread panel or search closes on Esc |
| TUI-T16 | Community switch | Click community in sidebar → channels update |
### 5.5 TUI Messaging
### 6.5 TUI Messaging
| ID | Test | Description |
|----|------|-------------|
@@ -393,7 +484,7 @@ Last-Event-ID: <id> (for reconnection)
| TUI-T26 | Reply in thread | Type in thread input → reply sent |
| TUI-T27 | Image paste | Not supported in TUI (CLI `--image` flag instead) |
### 5.6 TUI Real-Time
### 6.6 TUI Real-Time
| ID | Test | Description |
|----|------|-------------|
@@ -404,7 +495,7 @@ Last-Event-ID: <id> (for reconnection)
| TUI-T32 | SSE reconnect | Connection lost → "Reconnecting..." → auto-reconnects |
| TUI-T33 | Bell notification | Terminal bell on @mention or DM |
### 5.7 TUI Rendering
### 6.7 TUI Rendering
| ID | Test | Description |
|----|------|-------------|
@@ -422,7 +513,7 @@ Last-Event-ID: <id> (for reconnection)
| TUI-T45 | Long message wrapping | Long messages wrap correctly within pane width |
| TUI-T46 | Terminal resize | Layout reflows on terminal resize event |
### 5.8 TUI Error Handling
### 6.8 TUI Error Handling
| ID | Test | Description |
|----|------|-------------|

Some files were not shown because too many files have changed in this diff Show More