diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7cb2cbe --- /dev/null +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 522e587..8524264 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..12c5253 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/bb.edn b/api/bb.edn new file mode 100644 index 0000000..2375076 --- /dev/null +++ b/api/bb.edn @@ -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")}}} diff --git a/api/resources/api-config.edn b/api/resources/api-config.edn new file mode 100644 index 0000000..784658d --- /dev/null +++ b/api/resources/api-config.edn @@ -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}}}} diff --git a/api/resources/migrations/001-create-users.down.sql b/api/resources/migrations/001-create-users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/api/resources/migrations/001-create-users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/api/resources/migrations/001-create-users.up.sql b/api/resources/migrations/001-create-users.up.sql new file mode 100644 index 0000000..435fc70 --- /dev/null +++ b/api/resources/migrations/001-create-users.up.sql @@ -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() +); diff --git a/api/resources/migrations/002-create-oauth-accounts.down.sql b/api/resources/migrations/002-create-oauth-accounts.down.sql new file mode 100644 index 0000000..efaf90f --- /dev/null +++ b/api/resources/migrations/002-create-oauth-accounts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS oauth_accounts; diff --git a/api/resources/migrations/002-create-oauth-accounts.up.sql b/api/resources/migrations/002-create-oauth-accounts.up.sql new file mode 100644 index 0000000..a7e23d0 --- /dev/null +++ b/api/resources/migrations/002-create-oauth-accounts.up.sql @@ -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) +); diff --git a/api/resources/migrations/003-create-communities.down.sql b/api/resources/migrations/003-create-communities.down.sql new file mode 100644 index 0000000..d1aad65 --- /dev/null +++ b/api/resources/migrations/003-create-communities.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS communities; diff --git a/api/resources/migrations/003-create-communities.up.sql b/api/resources/migrations/003-create-communities.up.sql new file mode 100644 index 0000000..c444965 --- /dev/null +++ b/api/resources/migrations/003-create-communities.up.sql @@ -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() +); diff --git a/api/resources/migrations/004-create-community-members.down.sql b/api/resources/migrations/004-create-community-members.down.sql new file mode 100644 index 0000000..38aecef --- /dev/null +++ b/api/resources/migrations/004-create-community-members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS community_members; diff --git a/api/resources/migrations/004-create-community-members.up.sql b/api/resources/migrations/004-create-community-members.up.sql new file mode 100644 index 0000000..3778859 --- /dev/null +++ b/api/resources/migrations/004-create-community-members.up.sql @@ -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) +); diff --git a/api/resources/migrations/005-create-channel-categories.down.sql b/api/resources/migrations/005-create-channel-categories.down.sql new file mode 100644 index 0000000..4248205 --- /dev/null +++ b/api/resources/migrations/005-create-channel-categories.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_categories; diff --git a/api/resources/migrations/005-create-channel-categories.up.sql b/api/resources/migrations/005-create-channel-categories.up.sql new file mode 100644 index 0000000..f120b7e --- /dev/null +++ b/api/resources/migrations/005-create-channel-categories.up.sql @@ -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 +); diff --git a/api/resources/migrations/006-create-channels.down.sql b/api/resources/migrations/006-create-channels.down.sql new file mode 100644 index 0000000..1d2529d --- /dev/null +++ b/api/resources/migrations/006-create-channels.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channels; diff --git a/api/resources/migrations/006-create-channels.up.sql b/api/resources/migrations/006-create-channels.up.sql new file mode 100644 index 0000000..c075272 --- /dev/null +++ b/api/resources/migrations/006-create-channels.up.sql @@ -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() +); diff --git a/api/resources/migrations/007-create-channel-members.down.sql b/api/resources/migrations/007-create-channel-members.down.sql new file mode 100644 index 0000000..a1a6f63 --- /dev/null +++ b/api/resources/migrations/007-create-channel-members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_members; diff --git a/api/resources/migrations/007-create-channel-members.up.sql b/api/resources/migrations/007-create-channel-members.up.sql new file mode 100644 index 0000000..813e23e --- /dev/null +++ b/api/resources/migrations/007-create-channel-members.up.sql @@ -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) +); diff --git a/api/resources/migrations/008-create-messages.down.sql b/api/resources/migrations/008-create-messages.down.sql new file mode 100644 index 0000000..cbe8189 --- /dev/null +++ b/api/resources/migrations/008-create-messages.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS messages; diff --git a/api/resources/migrations/008-create-messages.up.sql b/api/resources/migrations/008-create-messages.up.sql new file mode 100644 index 0000000..07771c0 --- /dev/null +++ b/api/resources/migrations/008-create-messages.up.sql @@ -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 +); diff --git a/api/resources/migrations/009-create-attachments.down.sql b/api/resources/migrations/009-create-attachments.down.sql new file mode 100644 index 0000000..318e120 --- /dev/null +++ b/api/resources/migrations/009-create-attachments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS attachments; diff --git a/api/resources/migrations/009-create-attachments.up.sql b/api/resources/migrations/009-create-attachments.up.sql new file mode 100644 index 0000000..133fb5f --- /dev/null +++ b/api/resources/migrations/009-create-attachments.up.sql @@ -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 +); diff --git a/api/resources/migrations/010-create-reactions.down.sql b/api/resources/migrations/010-create-reactions.down.sql new file mode 100644 index 0000000..8c0b092 --- /dev/null +++ b/api/resources/migrations/010-create-reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS reactions; diff --git a/api/resources/migrations/010-create-reactions.up.sql b/api/resources/migrations/010-create-reactions.up.sql new file mode 100644 index 0000000..e6d4e7b --- /dev/null +++ b/api/resources/migrations/010-create-reactions.up.sql @@ -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) +); diff --git a/api/resources/migrations/011-create-mentions.down.sql b/api/resources/migrations/011-create-mentions.down.sql new file mode 100644 index 0000000..6540bbb --- /dev/null +++ b/api/resources/migrations/011-create-mentions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS mentions; diff --git a/api/resources/migrations/011-create-mentions.up.sql b/api/resources/migrations/011-create-mentions.up.sql new file mode 100644 index 0000000..f721ee5 --- /dev/null +++ b/api/resources/migrations/011-create-mentions.up.sql @@ -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 +); diff --git a/api/resources/migrations/012-create-notifications.down.sql b/api/resources/migrations/012-create-notifications.down.sql new file mode 100644 index 0000000..d5d18e1 --- /dev/null +++ b/api/resources/migrations/012-create-notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS notifications; diff --git a/api/resources/migrations/012-create-notifications.up.sql b/api/resources/migrations/012-create-notifications.up.sql new file mode 100644 index 0000000..84d57fb --- /dev/null +++ b/api/resources/migrations/012-create-notifications.up.sql @@ -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() +); diff --git a/api/resources/migrations/013-create-sessions.down.sql b/api/resources/migrations/013-create-sessions.down.sql new file mode 100644 index 0000000..63d205d --- /dev/null +++ b/api/resources/migrations/013-create-sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sessions; diff --git a/api/resources/migrations/013-create-sessions.up.sql b/api/resources/migrations/013-create-sessions.up.sql new file mode 100644 index 0000000..cca9e60 --- /dev/null +++ b/api/resources/migrations/013-create-sessions.up.sql @@ -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() +); diff --git a/api/resources/migrations/014-create-api-users.down.sql b/api/resources/migrations/014-create-api-users.down.sql new file mode 100644 index 0000000..e9ac9a9 --- /dev/null +++ b/api/resources/migrations/014-create-api-users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS api_users; diff --git a/api/resources/migrations/014-create-api-users.up.sql b/api/resources/migrations/014-create-api-users.up.sql new file mode 100644 index 0000000..1c968b6 --- /dev/null +++ b/api/resources/migrations/014-create-api-users.up.sql @@ -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() +); diff --git a/api/resources/migrations/015-create-api-tokens.down.sql b/api/resources/migrations/015-create-api-tokens.down.sql new file mode 100644 index 0000000..a844841 --- /dev/null +++ b/api/resources/migrations/015-create-api-tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS api_tokens; diff --git a/api/resources/migrations/015-create-api-tokens.up.sql b/api/resources/migrations/015-create-api-tokens.up.sql new file mode 100644 index 0000000..c1669d5 --- /dev/null +++ b/api/resources/migrations/015-create-api-tokens.up.sql @@ -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() +); diff --git a/api/resources/migrations/016-create-webhooks.down.sql b/api/resources/migrations/016-create-webhooks.down.sql new file mode 100644 index 0000000..0457cf1 --- /dev/null +++ b/api/resources/migrations/016-create-webhooks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webhooks; diff --git a/api/resources/migrations/016-create-webhooks.up.sql b/api/resources/migrations/016-create-webhooks.up.sql new file mode 100644 index 0000000..442a534 --- /dev/null +++ b/api/resources/migrations/016-create-webhooks.up.sql @@ -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() +); diff --git a/api/resources/migrations/017-create-invites.down.sql b/api/resources/migrations/017-create-invites.down.sql new file mode 100644 index 0000000..7888b43 --- /dev/null +++ b/api/resources/migrations/017-create-invites.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS invites; diff --git a/api/resources/migrations/017-create-invites.up.sql b/api/resources/migrations/017-create-invites.up.sql new file mode 100644 index 0000000..67e477f --- /dev/null +++ b/api/resources/migrations/017-create-invites.up.sql @@ -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() +); diff --git a/api/resources/migrations/018-add-search-indexes.down.sql b/api/resources/migrations/018-add-search-indexes.down.sql new file mode 100644 index 0000000..5e6284d --- /dev/null +++ b/api/resources/migrations/018-add-search-indexes.down.sql @@ -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; diff --git a/api/resources/migrations/018-add-search-indexes.up.sql b/api/resources/migrations/018-add-search-indexes.up.sql new file mode 100644 index 0000000..79f9cc3 --- /dev/null +++ b/api/resources/migrations/018-add-search-indexes.up.sql @@ -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); diff --git a/api/resources/migrations/019-create-bans.down.sql b/api/resources/migrations/019-create-bans.down.sql new file mode 100644 index 0000000..d0badcf --- /dev/null +++ b/api/resources/migrations/019-create-bans.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS bans; diff --git a/api/resources/migrations/019-create-bans.up.sql b/api/resources/migrations/019-create-bans.up.sql new file mode 100644 index 0000000..9f1af54 --- /dev/null +++ b/api/resources/migrations/019-create-bans.up.sql @@ -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) +); diff --git a/api/resources/migrations/020-create-mutes.down.sql b/api/resources/migrations/020-create-mutes.down.sql new file mode 100644 index 0000000..7adba75 --- /dev/null +++ b/api/resources/migrations/020-create-mutes.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_mutes_expires; +DROP TABLE IF EXISTS mutes; diff --git a/api/resources/migrations/020-create-mutes.up.sql b/api/resources/migrations/020-create-mutes.up.sql new file mode 100644 index 0000000..450af0a --- /dev/null +++ b/api/resources/migrations/020-create-mutes.up.sql @@ -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; diff --git a/api/resources/migrations/021-create-oauth-providers.down.sql b/api/resources/migrations/021-create-oauth-providers.down.sql new file mode 100644 index 0000000..d45bfc8 --- /dev/null +++ b/api/resources/migrations/021-create-oauth-providers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS oauth_providers; diff --git a/api/resources/migrations/021-create-oauth-providers.up.sql b/api/resources/migrations/021-create-oauth-providers.up.sql new file mode 100644 index 0000000..1b7e898 --- /dev/null +++ b/api/resources/migrations/021-create-oauth-providers.up.sql @@ -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); diff --git a/api/resources/migrations/022-create-system-settings.down.sql b/api/resources/migrations/022-create-system-settings.down.sql new file mode 100644 index 0000000..1e2a497 --- /dev/null +++ b/api/resources/migrations/022-create-system-settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS system_settings; diff --git a/api/resources/migrations/022-create-system-settings.up.sql b/api/resources/migrations/022-create-system-settings.up.sql new file mode 100644 index 0000000..87f0fcf --- /dev/null +++ b/api/resources/migrations/022-create-system-settings.up.sql @@ -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; diff --git a/api/src/ajet/chat/api/core.clj b/api/src/ajet/chat/api/core.clj index c30a665..309ef1a 100644 --- a/api/src/ajet/chat/api/core.clj +++ b/api/src/ajet/chat/api/core.clj @@ -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)) diff --git a/api/src/ajet/chat/api/handlers/admin.clj b/api/src/ajet/chat/api/handlers/admin.clj new file mode 100644 index 0000000..67c2c7b --- /dev/null +++ b/api/src/ajet/chat/api/handlers/admin.clj @@ -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))) diff --git a/api/src/ajet/chat/api/handlers/categories.clj b/api/src/ajet/chat/api/handlers/categories.clj new file mode 100644 index 0000000..fcdada8 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/categories.clj @@ -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))) diff --git a/api/src/ajet/chat/api/handlers/channels.clj b/api/src/ajet/chat/api/handlers/channels.clj new file mode 100644 index 0000000..eb78738 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/channels.clj @@ -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)))) diff --git a/api/src/ajet/chat/api/handlers/commands.clj b/api/src/ajet/chat/api/handlers/commands.clj new file mode 100644 index 0000000..1901da8 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/commands.clj @@ -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 @ syntax." + [s] + (when-let [[_ uid] (re-matches #"@" 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 " :permission "Admin+"} + "nick" {:description "Set community nickname" :usage "/nick " :permission "All"} + "invite" {:description "Generate invite link" :usage "/invite [max_uses] [expires_hours]" :permission "Admin+"} + "kick" {:description "Kick user from community" :usage "/kick @" :permission "Admin+"} + "ban" {:description "Ban user from community" :usage "/ban @" :permission "Admin+"} + "mute" {:description "Mute user for duration" :usage "/mute @ " :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 " :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 " {: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 " {: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 @" {:type :ajet.chat/validation-error}))) + (let [target-uid (or (parse-user-mention (first args)) + (throw (ex-info "Invalid user mention. Use @ 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 @" {:type :ajet.chat/validation-error}))) + (let [target-uid (or (parse-user-mention (first args)) + (throw (ex-info "Invalid user mention. Use @ 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 @ (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 @ 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 " + {: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 " + {: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 " + {: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 [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 " + {: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 " + {: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 @\", \"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.")}))))) diff --git a/api/src/ajet/chat/api/handlers/communities.clj b/api/src/ajet/chat/api/handlers/communities.clj new file mode 100644 index 0000000..0efe741 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/communities.clj @@ -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))) diff --git a/api/src/ajet/chat/api/handlers/dms.clj b/api/src/ajet/chat/api/handlers/dms.clj new file mode 100644 index 0000000..1766332 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/dms.clj @@ -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))))) diff --git a/api/src/ajet/chat/api/handlers/health.clj b/api/src/ajet/chat/api/handlers/health.clj new file mode 100644 index 0000000..6c84a89 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/health.clj @@ -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}))) diff --git a/api/src/ajet/chat/api/handlers/invites.clj b/api/src/ajet/chat/api/handlers/invites.clj new file mode 100644 index 0000000..d147b1a --- /dev/null +++ b/api/src/ajet/chat/api/handlers/invites.clj @@ -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))))))) diff --git a/api/src/ajet/chat/api/handlers/messages.clj b/api/src/ajet/chat/api/handlers/messages.clj new file mode 100644 index 0000000..1660289 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/messages.clj @@ -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=&after=&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"}))) diff --git a/api/src/ajet/chat/api/handlers/notifications.clj b/api/src/ajet/chat/api/handlers/notifications.clj new file mode 100644 index 0000000..eedbd8b --- /dev/null +++ b/api/src/ajet/chat/api/handlers/notifications.clj @@ -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=&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)}))) diff --git a/api/src/ajet/chat/api/handlers/presence.clj b/api/src/ajet/chat/api/handlers/presence.clj new file mode 100644 index 0000000..4954327 --- /dev/null +++ b/api/src/ajet/chat/api/handlers/presence.clj @@ -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))) diff --git a/api/src/ajet/chat/api/handlers/reactions.clj b/api/src/ajet/chat/api/handlers/reactions.clj new file mode 100644 index 0000000..ab6f70d --- /dev/null +++ b/api/src/ajet/chat/api/handlers/reactions.clj @@ -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)))) diff --git a/api/src/ajet/chat/api/handlers/search.clj b/api/src/ajet/chat/api/handlers/search.clj new file mode 100644 index 0000000..4a1ef6b --- /dev/null +++ b/api/src/ajet/chat/api/handlers/search.clj @@ -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)))) diff --git a/api/src/ajet/chat/api/handlers/upload.clj b/api/src/ajet/chat/api/handlers/upload.clj new file mode 100644 index 0000000..2caa57e --- /dev/null +++ b/api/src/ajet/chat/api/handlers/upload.clj @@ -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)}))))) diff --git a/api/src/ajet/chat/api/handlers/users.clj b/api/src/ajet/chat/api/handlers/users.clj new file mode 100644 index 0000000..6bed80d --- /dev/null +++ b/api/src/ajet/chat/api/handlers/users.clj @@ -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))) diff --git a/api/src/ajet/chat/api/handlers/webhooks.clj b/api/src/ajet/chat/api/handlers/webhooks.clj new file mode 100644 index 0000000..6856aad --- /dev/null +++ b/api/src/ajet/chat/api/handlers/webhooks.clj @@ -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))))) diff --git a/api/src/ajet/chat/api/middleware.clj b/api/src/ajet/chat/api/middleware.clj new file mode 100644 index 0000000..2371125 --- /dev/null +++ b/api/src/ajet/chat/api/middleware.clj @@ -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 {})))))) diff --git a/api/src/ajet/chat/api/routes.clj b/api/src/ajet/chat/api/routes.clj new file mode 100644 index 0000000..885c974 --- /dev/null +++ b/api/src/ajet/chat/api/routes.clj @@ -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))) diff --git a/auth-gw/Dockerfile b/auth-gw/Dockerfile new file mode 100644 index 0000000..9ab2cd0 --- /dev/null +++ b/auth-gw/Dockerfile @@ -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"] diff --git a/auth-gw/bb.edn b/auth-gw/bb.edn new file mode 100644 index 0000000..d38c9c9 --- /dev/null +++ b/auth-gw/bb.edn @@ -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")}}} diff --git a/auth-gw/deps.edn b/auth-gw/deps.edn index ecee479..548e3ee 100644 --- a/auth-gw/deps.edn +++ b/auth-gw/deps.edn @@ -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"] diff --git a/auth-gw/resources/auth-gw-config.edn b/auth-gw/resources/auth-gw-config.edn new file mode 100644 index 0000000..7ef8fe9 --- /dev/null +++ b/auth-gw/resources/auth-gw-config.edn @@ -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"]}}}} diff --git a/auth-gw/src/ajet/chat/auth_gw/auth.clj b/auth-gw/src/ajet/chat/auth_gw/auth.clj new file mode 100644 index 0000000..86c1d4f --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/auth.clj @@ -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])) diff --git a/auth-gw/src/ajet/chat/auth_gw/core.clj b/auth-gw/src/ajet/chat/auth_gw/core.clj index 766f57d..17fc342 100644 --- a/auth-gw/src/ajet/chat/auth_gw/core.clj +++ b/auth-gw/src/ajet/chat/auth_gw/core.clj @@ -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)) diff --git a/auth-gw/src/ajet/chat/auth_gw/middleware.clj b/auth-gw/src/ajet/chat/auth_gw/middleware.clj new file mode 100644 index 0000000..e2b2cc3 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/middleware.clj @@ -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))) diff --git a/auth-gw/src/ajet/chat/auth_gw/oauth.clj b/auth-gw/src/ajet/chat/auth_gw/oauth.clj new file mode 100644 index 0000000..5a6c7c3 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/oauth.clj @@ -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})}))) diff --git a/auth-gw/src/ajet/chat/auth_gw/pages.clj b/auth-gw/src/ajet/chat/auth_gw/pages.clj new file mode 100644 index 0000000..06e32a8 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/pages.clj @@ -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 + "" + (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" "" + "gitea" "" + "oidc" ""}) + +(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")]]])) diff --git a/auth-gw/src/ajet/chat/auth_gw/proxy.clj b/auth-gw/src/ajet/chat/auth_gw/proxy.clj new file mode 100644 index 0000000..32e722c --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/proxy.clj @@ -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))))))))) diff --git a/auth-gw/src/ajet/chat/auth_gw/rate_limit.clj b/auth-gw/src/ajet/chat/auth_gw/rate_limit.clj new file mode 100644 index 0000000..3e193c3 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/rate_limit.clj @@ -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))) diff --git a/auth-gw/src/ajet/chat/auth_gw/routes.clj b/auth-gw/src/ajet/chat/auth_gw/routes.clj new file mode 100644 index 0000000..6575141 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/routes.clj @@ -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})]]}))) diff --git a/auth-gw/src/ajet/chat/auth_gw/setup.clj b/auth-gw/src/ajet/chat/auth_gw/setup.clj new file mode 100644 index 0000000..94e3273 --- /dev/null +++ b/auth-gw/src/ajet/chat/auth_gw/setup.clj @@ -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))) diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..19c57da --- /dev/null +++ b/bb.edn @@ -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 " + :task (let [module (first *command-line-args*)] + (when-not module + (println "Usage: bb build (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 " + :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;'")}}} diff --git a/build.clj b/build.clj new file mode 100644 index 0000000..b63440a --- /dev/null +++ b/build.clj @@ -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")}))) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..1b1fc13 --- /dev/null +++ b/cli/README.md @@ -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) | diff --git a/cli/deps.edn b/cli/deps.edn index 2151a64..c97b86d 100644 --- a/cli/deps.edn +++ b/cli/deps.edn @@ -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 diff --git a/cli/src/ajet/chat/cli/auth.clj b/cli/src/ajet/chat/cli/auth.clj new file mode 100644 index 0000000..91240f3 --- /dev/null +++ b/cli/src/ajet/chat/cli/auth.clj @@ -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 + " +

Logged in!

+

You can close this tab and return to your terminal.

+ ") + +(def ^:private error-page + " +

Login failed

+

Something went wrong. Please try again.

+ ") + +(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 ' 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)) diff --git a/cli/src/ajet/chat/cli/commands.clj b/cli/src/ajet/chat/cli/commands.clj new file mode 100644 index 0000000..5c41204 --- /dev/null +++ b/cli/src/ajet/chat/cli/commands.clj @@ -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 or set a default with 'ajet config set default-community '"}))) + (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 [--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 [--stdin] [--image ]"}))) + (when (and (not stdin) (str/blank? message-text)) + (throw (ex-info "Message text required" + {:type :ajet.chat/usage-error + :hint "Usage: ajet send or echo 'msg' | ajet send --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 "}))) + (when (str/blank? new-text) + (throw (ex-info "New message text required" + {:type :ajet.chat/usage-error + :hint "Usage: ajet edit "}))) + (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 "}))) + (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 or ajet dm --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 or ajet dm --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 [--channel ] [--from ] [--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 "}))) + (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 "}))))) + +;;; --------------------------------------------------------------------------- +;;; 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 "}))) + (when (str/blank? value) + (throw (ex-info "Config value required" + {:type :ajet.chat/usage-error + :hint "Usage: ajet config set "}))) + (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 "}))) + (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)))) diff --git a/cli/src/ajet/chat/cli/config.clj b/cli/src/ajet/chat/cli/config.clj new file mode 100644 index 0000000..eea61a8 --- /dev/null +++ b/cli/src/ajet/chat/cli/config.clj @@ -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)))) diff --git a/cli/src/ajet/chat/cli/core.clj b/cli/src/ajet/chat/cli/core.clj index 303a4a4..6802896 100644 --- a/cli/src/ajet/chat/cli/core.clj +++ b/cli/src/ajet/chat/cli/core.clj @@ -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 " [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 Read messages in a channel\n" + " send Send a message\n" + " edit Edit a message\n" + " delete Delete a message\n" + " dms List DM conversations\n" + " dm Send a DM\n" + " notifications Manage notifications\n" + " search 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 " --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)))))))) diff --git a/cli/src/ajet/chat/cli/output.clj b/cli/src/ajet/chat/cli/output.clj new file mode 100644 index 0000000..fc61424 --- /dev/null +++ b/cli/src/ajet/chat/cli/output.clj @@ -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: + +
+ + Hint: " + ([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))) diff --git a/cli/src/ajet/chat/cli/tui.clj b/cli/src/ajet/chat/cli/tui.clj new file mode 100644 index 0000000..1c8b04a --- /dev/null +++ b/cli/src/ajet/chat/cli/tui.clj @@ -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))) diff --git a/deps.edn b/deps.edn index dd10ea4..fbc06cd 100644 --- a/deps.edn +++ b/deps.edn @@ -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}}} diff --git a/dev/user.clj b/dev/user.clj new file mode 100644 index 0000000..c3819de --- /dev/null +++ b/dev/user.clj @@ -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!) + ) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..c74f548 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..53c3cbb --- /dev/null +++ b/docker-compose.test.yml @@ -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 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a70769 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/prd/README.md b/docs/prd/README.md index 90ebfec..ea86c0e 100644 --- a/docs/prd/README.md +++ b/docs/prd/README.md @@ -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** — `@` / `@` / `#` 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 diff --git a/docs/prd/api.md b/docs/prd/api.md index 1936733..de2b41e 100644 --- a/docs/prd/api.md +++ b/docs/prd/api.md @@ -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`) diff --git a/docs/prd/auth-gw.md b/docs/prd/auth-gw.md index 0b89011..14571b6 100644 --- a/docs/prd/auth-gw.md +++ b/docs/prd/auth-gw.md @@ -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 ` 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 ` 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 ` 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: +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 FK→users 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) | diff --git a/docs/prd/cli.md b/docs/prd/cli.md index c3c9aa3..aabe6ca 100644 --- a/docs/prd/cli.md +++ b/docs/prd/cli.md @@ -310,9 +310,100 @@ Last-Event-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: + +
+ +Hint: +``` + +**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: (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: (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: (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: (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: (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: (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: (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 | |----|------|-------------| diff --git a/docs/prd/infrastructure.md b/docs/prd/infrastructure.md new file mode 100644 index 0000000..fda58a3 --- /dev/null +++ b/docs/prd/infrastructure.md @@ -0,0 +1,656 @@ +# PRD: Infrastructure & Deployment + +**Scope:** Docker Compose, NATS, MinIO, PostgreSQL, nginx, service topology +**Status:** v1 | **Last updated:** 2026-02-17 + +--- + +## 1. Overview + +Infrastructure configuration for dev, test, and production environments. All environments use the same backing services (PostgreSQL, NATS, MinIO) — only configuration differs. + +## 2. Service Ports + +### 2.1 Application Services (Dev) + +| Service | Port | Protocol | +|---------|------|----------| +| Auth Gateway | 3000 | HTTP | +| API | 3001 | HTTP | +| Web SM | 3002 | HTTP | +| TUI SM | 3003 | HTTP | + +### 2.2 Infrastructure Services (Dev) + +| Service | Port(s) | Protocol | +|---------|---------|----------| +| PostgreSQL | 5432 | TCP | +| NATS | 4222 (client), 8222 (monitoring) | TCP, HTTP | +| MinIO | 9000 (API), 9001 (console) | HTTP | + +### 2.3 Production + +| Service | Port | Exposed | +|---------|------|---------| +| nginx | 80, 443 | External | +| Auth Gateway | 3000 | Internal only | +| API | 3001 | Internal only | +| Web SM | 3002 | Internal only | +| TUI SM | 3003 | Internal only | +| PostgreSQL | 5432 | Internal only | +| NATS | 4222 | Internal only | +| MinIO | 9000 | Internal only | + +--- + +## 3. Docker Compose — Development + +**File:** `docker-compose.dev.yml` + +Runs infrastructure services only. Clojure services run locally via REPL. + +```yaml +# docker-compose.dev.yml +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", "nats-server", "--signal", "ldm"] + 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: +``` + +**Usage:** +```bash +docker compose -f docker-compose.dev.yml up -d +# Then start Clojure services via REPL +clj -A:dev:api:web-sm:tui-sm:auth-gw +``` + +--- + +## 4. Docker Compose — Test + +**File:** `docker-compose.test.yml` + +Fresh database per run. Separate ports to avoid conflicts with dev. + +```yaml +# docker-compose.test.yml +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 # ephemeral — fresh DB each run + 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"] # JetStream enabled, no persistent storage + healthcheck: + test: ["CMD", "nats-server", "--signal", "ldm"] + 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 # ephemeral — no persistent files + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 3s + timeout: 2s + retries: 10 + + minio-test-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; + " + + # E2E profile — adds application service containers + auth-gw: + profiles: ["e2e"] + build: + context: . + dockerfile: auth-gw/Dockerfile + ports: + - "3100: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 + depends_on: + postgres-test: + condition: service_healthy + + api: + profiles: ["e2e"] + build: + context: . + dockerfile: api/Dockerfile + 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 + depends_on: + postgres-test: + condition: service_healthy + nats-test: + condition: service_healthy + minio-test-init: + condition: service_completed_successfully + + web-sm: + profiles: ["e2e"] + build: + context: . + dockerfile: web-sm/Dockerfile + environment: + AJET_API_BASE_URL: http://api:3001 + AJET_NATS_URL: nats://nats-test:4222 + depends_on: + nats-test: + condition: service_healthy + + tui-sm: + profiles: ["e2e"] + build: + context: . + dockerfile: tui-sm/Dockerfile + environment: + AJET_API_BASE_URL: http://api:3001 + AJET_NATS_URL: nats://nats-test:4222 + depends_on: + nats-test: + condition: service_healthy +``` + +**Usage:** +```bash +# Unit + integration tests +docker compose -f docker-compose.test.yml up -d +clj -M:test/unit +clj -M:test/integration + +# E2E tests (full stack) +docker compose -f docker-compose.test.yml --profile e2e up -d --build +clj -M:test/e2e + +# Teardown +docker compose -f docker-compose.test.yml --profile e2e down -v +``` + +--- + +## 5. Docker Compose — Production + +**File:** `docker-compose.yml` + +Full stack with nginx TLS termination. + +```yaml +# docker-compose.yml +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", "nats-server", "--signal", "ldm"] + 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: +``` + +**Environment file (`.env`):** +``` +AJET_DB_PASSWORD= +GITHUB_CLIENT_ID= # seed only — migrated to DB on first start +GITHUB_CLIENT_SECRET= # seed only — migrated to DB on first start +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +``` + +**Note:** OAuth credentials in `.env` are auto-migrated to the `oauth_providers` DB table on first startup. After that, manage OAuth providers via the admin setup wizard or the admin API. You can also skip these env vars entirely and configure providers through the setup wizard on first deployment. + +--- + +## 6. nginx Configuration (Production) + +**File:** `nginx/nginx.conf` + +```nginx +worker_processes auto; + +events { + worker_connections 4096; +} + +http { + upstream auth_gw { + server auth-gw:3000; + } + + # Redirect HTTP → HTTPS + server { + listen 80; + server_name chat.example.com; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl http2; + server_name chat.example.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # Proxy all traffic to Auth Gateway + location / { + proxy_pass http://auth_gw; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support — disable buffering + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; # 24h for SSE connections + proxy_send_timeout 86400s; + + # WebSocket support (future, for voice) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Static file size limit + client_max_body_size 10m; + } +} +``` + +--- + +## 7. NATS JetStream Configuration + +### 7.1 Stream Setup + +Created automatically by the API service on startup: + +```clojure +;; Stream: ajet-events +{:name "ajet-events" + :subjects ["chat.events.>" + "chat.dm.>" + "chat.typing.>" + "chat.presence.>" + "chat.notifications.>" + "chat.audit"] + :retention :limits ;; retain by limits (not interest or work-queue) + :max-age 86400000000000 ;; 24 hours in nanoseconds + :max-bytes 1073741824 ;; 1 GB + :storage :file + :replicas 1 ;; single node for v1 + :discard :old} ;; discard oldest when limits hit +``` + +### 7.2 Consumer Setup + +Each session manager creates a durable consumer on connect: + +```clojure +;; Consumer: per-user, created by SM on SSE connection +{:durable-name "sm-{service}-{user-id}" ;; e.g. "sm-web-abc123" + :filter-subjects ["chat.events.{community-id}" + "chat.dm.{channel-id-1}" + "chat.dm.{channel-id-2}" + "chat.notifications.{user-id}"] + :deliver-policy :by-start-time ;; on reconnect: from last-event-id time + :ack-policy :none} ;; no ack needed for real-time delivery +``` + +### 7.3 Subject ACLs (Future) + +Not needed for v1 (single NATS instance, trusted services). For multi-tenant production: +- API: publish to all subjects +- SMs: subscribe only to subjects relevant to their connected users +- No direct client access to NATS (all mediated by SMs) + +--- + +## 8. MinIO Configuration + +### 8.1 Bucket Setup + +Single bucket `ajet-chat` with: +- Default retention: none (files kept indefinitely) +- Versioning: disabled (no need for file history in v1) + +### 8.2 Storage Key Convention + +``` +attachments/{message-uuid}/{filename} — message attachments +avatars/users/{user-uuid}/{filename} — user avatars +avatars/communities/{community-uuid}/{filename} — community icons +avatars/webhooks/{webhook-uuid}/{filename} — webhook bot icons +``` + +### 8.3 Access Patterns + +- **Upload:** API service writes to MinIO on file upload +- **Download:** Auth GW proxies `/files/*` requests to MinIO (or API generates presigned URLs) +- **No direct client access** to MinIO in v1 — all through API/Auth GW + +--- + +## 9. PostgreSQL Configuration + +### 9.1 Recommended Settings (Production) + +``` +shared_buffers = 256MB # 25% of available RAM +work_mem = 16MB +effective_cache_size = 768MB # 75% of available RAM +maintenance_work_mem = 128MB +max_connections = 100 +log_min_duration_statement = 500 # log queries > 500ms +``` + +### 9.2 Backups + +- **pg_dump** daily cron job (simple, sufficient for v1) +- Dump to local volume, rotate 7 days +- Future: WAL archiving for point-in-time recovery + +--- + +## 10. Dockerfile Template + +Each service uses the same multi-stage build: + +```dockerfile +# {service}/Dockerfile +FROM clojure:temurin-21-tools-deps-1.12 AS builder +WORKDIR /app +COPY deps.edn . +COPY shared/ shared/ +COPY {service}/ {service}/ +RUN clj -T:build uber :module {service} + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/{service}.jar app.jar +EXPOSE {port} +CMD ["java", "-jar", "app.jar"] +``` + +**Build alias** (root `deps.edn`): +```clojure +:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"}} + :ns-default build} +``` + +--- + +## 11. Environment Variables Reference + +### 11.1 All Services + +| Variable | Default | Description | +|----------|---------|-------------| +| `AJET_DB_HOST` | `localhost` | PostgreSQL host | +| `AJET_DB_PORT` | `5432` | PostgreSQL port | +| `AJET_DB_DBNAME` | `ajet_chat` | Database name | +| `AJET_DB_USER` | `ajet` | Database user | +| `AJET_DB_PASSWORD` | — | Database password (required) | +| `AJET_NATS_URL` | `nats://localhost:4222` | NATS server URL | +| `AJET_MINIO_ENDPOINT` | `http://localhost:9000` | MinIO endpoint | +| `AJET_MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key | +| `AJET_MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key | + +### 11.2 Auth Gateway Only + +| Variable | Default | Description | +|----------|---------|-------------| +| `AJET_OAUTH_GITHUB_CLIENT_ID` | — | GitHub OAuth app client ID (seed only — auto-migrated to DB) | +| `AJET_OAUTH_GITHUB_CLIENT_SECRET` | — | GitHub OAuth app client secret (seed only — auto-migrated to DB) | +| `AJET_OAUTH_GITEA_CLIENT_ID` | — | Gitea OAuth client ID (seed only — auto-migrated to DB) | +| `AJET_OAUTH_GITEA_CLIENT_SECRET` | — | Gitea OAuth client secret (seed only — auto-migrated to DB) | +| `AJET_OAUTH_GITEA_BASE_URL` | — | Gitea instance URL (seed only — auto-migrated to DB) | +| `AJET_OAUTH_OIDC_CLIENT_ID` | — | OIDC client ID (seed only — auto-migrated to DB) | +| `AJET_OAUTH_OIDC_CLIENT_SECRET` | — | OIDC client secret (seed only — auto-migrated to DB) | +| `AJET_OAUTH_OIDC_ISSUER_URL` | — | OIDC issuer URL (seed only — auto-migrated to DB) | +| `AJET_SERVICES_API_HOST` | `localhost` | API service host | +| `AJET_SERVICES_API_PORT` | `3001` | API service port | +| `AJET_SERVICES_WEB_SM_HOST` | `localhost` | Web SM host | +| `AJET_SERVICES_WEB_SM_PORT` | `3002` | Web SM port | +| `AJET_SERVICES_TUI_SM_HOST` | `localhost` | TUI SM host | +| `AJET_SERVICES_TUI_SM_PORT` | `3003` | TUI SM port | + +**Note on OAuth env vars:** The `AJET_OAUTH_*` environment variables serve as a seed for initial deployment only. On first startup, if the `oauth_providers` DB table is empty, Auth GW auto-migrates these values into the table. After migration, providers are managed exclusively via the admin API (`/api/admin/oauth-providers`) or the setup wizard. The env vars are ignored once providers exist in the DB. + +### 11.3 Session Managers Only + +| Variable | Default | Description | +|----------|---------|-------------| +| `AJET_API_BASE_URL` | `http://localhost:3001` | Internal API base URL | + +--- + +## 12. Test Cases + +| ID | Test | Type | Description | +|----|------|------|-------------| +| INFRA-T1 | Dev compose starts | Integration | `docker compose -f docker-compose.dev.yml up` starts PG + NATS + MinIO | +| INFRA-T2 | PG accessible | Integration | Can connect to Postgres on port 5432 | +| INFRA-T3 | NATS accessible | Integration | Can connect to NATS on port 4222 | +| INFRA-T4 | MinIO accessible | Integration | Can connect to MinIO on port 9000 | +| INFRA-T5 | MinIO bucket created | Integration | `ajet-chat` bucket exists after `minio-init` | +| INFRA-T6 | Test compose fresh DB | Integration | Test DB has no data from previous runs | +| INFRA-T7 | JetStream enabled | Integration | NATS JetStream API responds to stream info request | +| INFRA-T8 | Health checks pass | Integration | All service health checks return healthy | +| INFRA-T9 | Prod compose full stack | E2E | All services start and connect to each other | +| INFRA-T10 | nginx proxies to auth-gw | E2E | HTTPS request reaches Auth Gateway via nginx | diff --git a/docs/prd/shared.md b/docs/prd/shared.md index 75ab6e5..884e49f 100644 --- a/docs/prd/shared.md +++ b/docs/prd/shared.md @@ -204,6 +204,8 @@ webhook: {:id uuid, :community-id uuid, :channel-id uuid, :name string, mention: {:id uuid, :message-id uuid, :target-type enum, :target-id uuid?} notification: {:id uuid, :user-id uuid, :type enum, :source-id uuid, :read boolean} invite: {:id uuid, :community-id uuid, :created-by uuid, :code string, :max-uses int?, :uses int, :expires-at inst?} +oauth-provider: {:id uuid, :provider-type enum, :name string, :client-id string, :client-secret-encrypted string, :base-url string?, :issuer-url string?, :enabled boolean, :created-at inst, :updated-at inst} +system-setting: {:key string, :value any, :updated-at inst} ``` **Test Cases:** @@ -298,3 +300,130 @@ invite: {:id uuid, :community-id uuid, :created-by uuid, :code string, | MD-T13 | ANSI rendering for TUI | Unit | `**bold**` → ANSI bold escape code | | MD-T14 | Code block in ANSI | Unit | Code block rendered with box-drawing characters | | MD-T15 | Mentions not processed | Unit | Markdown processor does not handle `@` (separate concern) | + +--- + +### 2.7 Config Loader (`ajet.chat.shared.config`) + +**Purpose:** Load, validate, and merge EDN configuration from files and environment variables. All modules use this for startup configuration. + +**Requirements:** + +| ID | Requirement | Priority | +|----|-------------|----------| +| CFG-1 | Load config from EDN file path (default: `config.edn` in classpath) | P0 | +| CFG-2 | Deep-merge module-specific config over shared defaults | P0 | +| CFG-3 | Override any config key via environment variables (`AJET_DB_HOST` → `{:db {:host ...}}`) | P0 | +| CFG-4 | Validate required keys on load, throw clear error on missing/invalid config | P0 | +| CFG-5 | Support profiles (`:dev`, `:test`, `:prod`) — merge profile-specific overrides | P1 | +| CFG-6 | Secrets from env vars only — never log or serialize secret values | P0 | + +**Env var mapping convention:** +``` +AJET_DB_HOST → {:db {:host "..."}} +AJET_DB_PORT → {:db {:port 5432}} +AJET_DB_PASSWORD → {:db {:password "..."}} +AJET_NATS_URL → {:nats {:url "..."}} +AJET_OAUTH_GITHUB_CLIENT_ID → {:oauth {:github {:client-id "..."}}} +``` +Underscores map to nested keys. Numeric strings auto-coerce to integers. `"true"`/`"false"` coerce to booleans. + +**Shared defaults:** +```clojure +{:db {:host "localhost" :port 5432 :dbname "ajet_chat" :user "ajet" :pool-size 10} + :nats {:url "nats://localhost:4222"} + :minio {:endpoint "http://localhost:9000" :access-key "minioadmin" :secret-key "minioadmin" :bucket "ajet-chat"}} +``` + +**Test Cases:** + +| ID | Test | Type | Description | +|----|------|------|-------------| +| CFG-T1 | Load valid EDN config | Unit | `load-config` parses EDN file and returns map | +| CFG-T2 | Missing config file | Unit | Throws with clear error message | +| CFG-T3 | Env var override | Unit | `AJET_DB_HOST=remote` overrides `{:db {:host "localhost"}}` | +| CFG-T4 | Env var numeric coercion | Unit | `AJET_DB_PORT=5433` becomes integer 5433 | +| CFG-T5 | Deep merge module config | Unit | Module config merges over defaults without clobbering sibling keys | +| CFG-T6 | Missing required key | Unit | Missing `:db :host` throws validation error | +| CFG-T7 | Profile merge | Unit | `:test` profile overrides `{:db {:dbname "ajet_chat_test"}}` | +| CFG-T8 | Secrets not logged | Unit | Config with `:password` redacts value in string representation | + +--- + +### 2.8 Logging (`ajet.chat.shared.logging`) + +**Purpose:** Structured logging with trace ID propagation across all services. + +**Requirements:** + +| ID | Requirement | Priority | +|----|-------------|----------| +| LOG-1 | Use `clojure.tools.logging` with Logback backend | P0 | +| LOG-2 | Structured JSON log format in production, human-readable in dev | P0 | +| LOG-3 | Bind `trace-id` to MDC (Mapped Diagnostic Context) per request | P0 | +| LOG-4 | Log request/response summary for every HTTP request (method, path, status, duration) | P0 | +| LOG-5 | Log level configurable per namespace via config | P1 | +| LOG-6 | Redact sensitive fields (passwords, tokens) in log output | P0 | + +**MDC fields per request:** +``` +trace-id: UUID from X-Trace-Id header +user-id: UUID from X-User-Id header (if authenticated) +method: HTTP method +path: Request path +``` + +**Log format (production):** +```json +{"timestamp":"2026-02-17T10:30:00Z","level":"INFO","logger":"ajet.chat.api.routes","trace-id":"uuid","user-id":"uuid","msg":"POST /api/channels/uuid/messages 201 (45ms)"} +``` + +**Test Cases:** + +| ID | Test | Type | Description | +|----|------|------|-------------| +| LOG-T1 | Request logging middleware | Unit | Request/response logged with method, path, status, duration | +| LOG-T2 | Trace ID in MDC | Unit | Log entries include trace-id from request header | +| LOG-T3 | Sensitive field redaction | Unit | Password and token values replaced with `[REDACTED]` | +| LOG-T4 | JSON format in prod | Unit | Log output parses as valid JSON in `:prod` profile | +| LOG-T5 | Human-readable in dev | Unit | Log output is plain text with colors in `:dev` profile | + +--- + +### 2.9 File Storage Client (`ajet.chat.shared.storage`) + +**Purpose:** S3-compatible client for MinIO/AWS S3 file operations. + +**Requirements:** + +| ID | Requirement | Priority | +|----|-------------|----------| +| FS-1 | `upload!` — upload bytes/stream to storage with key | P0 | +| FS-2 | `download` — get file bytes/stream by key | P0 | +| FS-3 | `delete!` — remove file by key | P0 | +| FS-4 | `presigned-url` — generate time-limited download URL | P1 | +| FS-5 | Create bucket on startup if it doesn't exist | P0 | +| FS-6 | Validate content-type (images only: JPEG, PNG, GIF, WebP) | P0 | +| FS-7 | Validate file size (max 10MB) | P0 | +| FS-8 | Storage key format: `attachments/{uuid}/{filename}` | P0 | + +**Config shape:** +```clojure +{:minio {:endpoint "http://localhost:9000" + :access-key "minioadmin" + :secret-key "minioadmin" + :bucket "ajet-chat"}} +``` + +**Test Cases:** + +| ID | Test | Type | Description | +|----|------|------|-------------| +| FS-T1 | Upload file | Integration | `upload!` stores file, retrievable by key | +| FS-T2 | Download file | Integration | `download` returns same bytes as uploaded | +| FS-T3 | Delete file | Integration | `delete!` removes file, subsequent download returns nil | +| FS-T4 | Presigned URL | Integration | Generated URL is accessible and expires | +| FS-T5 | Invalid content-type | Unit | Non-image content-type throws validation error | +| FS-T6 | Oversized file | Unit | File > 10MB throws validation error | +| FS-T7 | Bucket auto-creation | Integration | On startup, creates bucket if missing | +| FS-T8 | Upload with storage key format | Unit | Key matches `attachments/{uuid}/{filename}` pattern | diff --git a/docs/prd/tui-sm.md b/docs/prd/tui-sm.md index 61dccef..adc444d 100644 --- a/docs/prd/tui-sm.md +++ b/docs/prd/tui-sm.md @@ -125,9 +125,75 @@ TUI SM buffers presence events to avoid flooding clients: - On flush: diff with previous state, send only changes - Typing indicators sent immediately (latency-sensitive) -## 7. Test Cases +## 7. Service Configuration -### 7.1 SSE Connection +### 7.1 Config Shape + +```clojure +{:server {:host "0.0.0.0" :port 3003} + :api {:base-url "http://localhost:3001"} + :nats {:url "nats://localhost:4222" + :stream-name "ajet-events"} + :session {:max-connections 5000 ;; max concurrent SSE connections + :ping-interval-sec 30} + :presence {:batch-interval-sec 60}} +``` + +### 7.2 Startup / Shutdown Sequence + +**Startup:** +``` +1. Load config +2. Connect to NATS +3. Initialize connection tracker (atom) +4. Start http-kit server +5. Start ping scheduler (30s interval) +6. Log "TUI SM started on port {port}" +``` + +**Shutdown (graceful):** +``` +1. Stop accepting new SSE connections +2. Send close event to all connected TUI clients +3. Stop ping scheduler +4. Unsubscribe all NATS subscriptions +5. Close NATS connection +6. Stop http-kit server +7. Log "TUI SM stopped" +``` + +### 7.3 Health Check + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/tui/health` | None | Service health | + +```json +{"status": "ok", "connections": 23, "checks": {"api": "ok", "nats": "ok"}} +``` + +### 7.4 Error Handling + +| Scenario | Behavior | +|----------|----------| +| API unavailable | Client signals (POST) return 502 JSON error. SSE stays open. | +| NATS unavailable | SSE stays open, no real-time events. Events resume when NATS reconnects. Status event sent to client. | +| Client sends invalid JSON | Return 400 with error description | +| Client sends to unauthorized channel | Return 403 | +| SSE write fails (dead connection) | Clean up connection state, unsubscribe NATS | +| Max connections reached | Return 503 `{"error": "max connections reached, retry later"}` | + +### 7.5 Backpressure + +- If a client's SSE buffer exceeds 1000 events, disconnect the client (forces reconnect with replay) +- Events are dropped (not queued) if write buffer is full — JetStream replay handles recovery +- Connection tracking atom is bounded by `max-connections` config + +--- + +## 8. Test Cases + +### 8.1 SSE Connection | ID | Test | Description | |----|------|-------------| @@ -138,7 +204,7 @@ TUI SM buffers presence events to avoid flooding clients: | TSM-T5 | Disconnect cleanup | SSE disconnect unsubscribes from NATS | | TSM-T6 | Reconnect with last_event_id | Missed events replayed via JetStream | -### 7.2 Real-Time Events +### 8.2 Real-Time Events | ID | Test | Description | |----|------|-------------| @@ -153,7 +219,7 @@ TUI SM buffers presence events to avoid flooding clients: | TSM-T15 | Notification event | User-targeted notification forwarded | | TSM-T16 | Unread count update | New message in other channel → unread count SSE event | -### 7.3 Client Signals +### 8.3 Client Signals | ID | Test | Description | |----|------|-------------| @@ -166,7 +232,7 @@ TUI SM buffers presence events to avoid flooding clients: | TSM-T23 | Slash command | POST /tui/command → API command → result | | TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API | -### 7.4 Edge Cases +### 8.4 Edge Cases | ID | Test | Description | |----|------|-------------| diff --git a/docs/prd/web-sm.md b/docs/prd/web-sm.md index f62b694..5d5fd82 100644 --- a/docs/prd/web-sm.md +++ b/docs/prd/web-sm.md @@ -314,7 +314,8 @@ All of these proxy to the API internally and return Datastar fragment responses. └──────────────────────────────────────┘ ``` -- Shown after first-ever OAuth login +**Note:** The initial first-deployment setup wizard (configure OAuth providers, admin login, create first community) is handled entirely by Auth GW at `/setup/*` routes. The Web SM `/setup` page shown here is used for **subsequent** community creation by already-authenticated users (e.g., clicking `+` on the community strip). + - Slug auto-generated from name (lowercase, hyphenated) - On submit: creates community + #general, redirects to /app @@ -351,9 +352,142 @@ Web SM maintains per-user connection state: - Red dot if unread mentions exist - Community icon badge: sum of unread mentions across all channels in that community -## 9. Test Cases +## 9. Styling & CSS -### 9.1 Page Rendering +### 9.1 Approach + +**Tailwind CSS** via CDN (play CDN for dev, self-hosted build for prod). + +- All layout uses Tailwind utility classes in Hiccup +- Custom CSS limited to: Datastar transition animations, syntax highlighting theme, spoiler reveal animation +- Dark theme only for v1 (Discord-style dark background) +- No CSS preprocessor — Tailwind utilities are sufficient + +### 9.2 Color Palette (Dark Theme) + +``` +Background: #1e1e2e (base) +Sidebar bg: #181825 (darker) +Message hover: #2a2a3c +Text primary: #cdd6f4 +Text secondary: #a6adc8 +Text muted: #6c7086 +Accent: #89b4fa (links, active channel) +Mention highlight: #f38ba8 (pink, @mention background) +Online dot: #a6e3a1 (green) +Unread badge: #f38ba8 (red) +Code bg: #313244 +Input bg: #313244 +Input border: #45475a +``` + +### 9.3 Emoji Picker + +**Implementation:** Lightweight emoji picker rendered server-side as a Hiccup grid. + +``` +┌──────────────────────────────────┐ +│ 🔍 Search emoji... │ +├──────────────────────────────────┤ +│ Frequently Used │ +│ 👍 ❤️ 😂 🎉 😮 😢 👀 🔥 │ +│ │ +│ Smileys & People │ +│ 😀 😃 😄 😁 😆 😅 🤣 😂 │ +│ 😊 😇 🙂 🙃 😉 😌 😍 🥰 │ +│ ... │ +├──────────────────────────────────┤ +│ 😀 👤 🐱 🍎 ⚽ 🚗 💡 🏳️ │ ← category tabs +└──────────────────────────────────┘ +``` + +- Triggered by clicking `+` on reaction bar or emoji button in input +- Server renders emoji grid as Datastar fragment +- Click emoji → POST to add reaction or insert into message input +- Search filters emoji list (server-side filter, returns fragment) +- Category tabs at bottom for quick navigation +- "Frequently Used" based on user's recent emoji (stored in session state) + +### 9.4 Notification Toasts + +New notifications (beyond badge counts) show as brief toast popups: + +``` +┌──────────────────────────────────────┐ +│ @alice mentioned you in #backend │ ← toast (top-right) +│ "hey @bob can you review this?" │ +│ ✕ │ +└──────────────────────────────────────┘ +``` + +- Toasts appear for: @mentions, DMs, thread replies +- Auto-dismiss after 5 seconds +- Click toast → navigate to the message +- Click ✕ → dismiss immediately +- Max 3 toasts visible simultaneously (queue overflow) +- Toasts rendered as Datastar fragments pushed via SSE + +## 10. Service Configuration + +### 10.1 Config Shape + +```clojure +{:server {:host "0.0.0.0" :port 3002} + :api {:base-url "http://localhost:3001"} + :nats {:url "nats://localhost:4222" + :stream-name "ajet-events"} + :assets {:tailwind-cdn true ;; false in prod (use built CSS) + :datastar-cdn true} ;; false in prod (vendored) + :session {:max-connections 10000} ;; max concurrent SSE connections + :ui {:messages-per-page 50 + :typing-timeout-sec 15 + :toast-duration-sec 5}} +``` + +### 10.2 Startup / Shutdown Sequence + +**Startup:** +``` +1. Load config +2. Connect to NATS +3. Initialize connection tracker (atom) +4. Start http-kit server +5. Log "Web SM started on port {port}" +``` + +**Shutdown (graceful):** +``` +1. Stop accepting new SSE connections +2. Send close event to all SSE clients +3. Unsubscribe all NATS subscriptions +4. Close NATS connection +5. Stop http-kit server +6. Log "Web SM stopped" +``` + +### 10.3 Health Check + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/web/health` | None | Service health | + +```json +{"status": "ok", "connections": 142, "checks": {"api": "ok", "nats": "ok"}} +``` + +### 10.4 Error Pages + +| Status | Page | Description | +|--------|------|-------------| +| 404 | Not Found | Unknown web route — shows "Page not found" with link to home | +| 500 | Server Error | Unhandled exception — shows "Something went wrong" with retry link | +| 502 | API Unavailable | API service unreachable — shows banner "Reconnecting..." with auto-retry | + +--- + +## 11. Test Cases + +### 11.1 Page Rendering | ID | Test | Description | |----|------|-------------| @@ -368,7 +502,7 @@ Web SM maintains per-user connection state: | WEB-T9 | Thread indicator | Messages with replies show reply count and link | | WEB-T10 | Setup wizard | GET /setup shows community creation form for first-time users | -### 9.2 SSE & Real-Time +### 11.2 SSE & Real-Time | ID | Test | Description | |----|------|-------------| @@ -386,7 +520,7 @@ Web SM maintains per-user connection state: | WEB-T22 | Community switch | Switching community updates sidebar and message list | | WEB-T23 | Channel switch | Switching channel loads new messages, marks old as read | -### 9.3 User Actions +### 11.3 User Actions | ID | Test | Description | |----|------|-------------| @@ -413,7 +547,7 @@ Web SM maintains per-user connection state: | WEB-T44 | Paginated loading | Scroll to "Load older" → click → older messages prepended | | WEB-T45 | Create community | Click + on community strip → wizard → community created | -### 9.4 Profile & Settings +### 11.4 Profile & Settings | ID | Test | Description | |----|------|-------------| @@ -421,7 +555,7 @@ Web SM maintains per-user connection state: | WEB-T47 | Set status | Click own avatar → status input → save | | WEB-T48 | Set nickname | In community settings → nickname field → save | -### 9.5 Error Handling +### 11.5 Error Handling | ID | Test | Description | |----|------|-------------| @@ -430,7 +564,7 @@ Web SM maintains per-user connection state: | WEB-T51 | Upload too large | Error shown, file not uploaded | | WEB-T52 | Rate limited | Error shown with retry countdown | -### 9.6 Responsive / Layout +### 11.6 Responsive / Layout | ID | Test | Description | |----|------|-------------| diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..a9f0a5b --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +screenshots/*.png +screenshots/*.jpeg +!screenshots/.gitkeep diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..59b7ea8 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,15 @@ +FROM clojure:temurin-21-tools-deps +RUN apt-get update && apt-get install -y --no-install-recommends wget && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY deps.edn build.clj tests.edn ./ +COPY shared/ shared/ +COPY api/ api/ +COPY auth-gw/ auth-gw/ +COPY web-sm/ web-sm/ +COPY tui-sm/ tui-sm/ +COPY test/ test/ +COPY e2e/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +RUN clj -P -M:test/base:test/e2e +ENTRYPOINT ["/entrypoint.sh"] +CMD ["clj", "-M:test/base:test/e2e"] diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..13b8282 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,97 @@ +# ajet-chat Playwright E2E Tests + +Browser-level end-to-end tests using [nbb](https://github.com/babashka/nbb) (Node Babashka) + [Playwright](https://playwright.dev/). + +## Port Scheme + +| Service | Dev (REPL) | Test (E2E) | Prod | +|------------|------------|------------|------------| +| auth-gw | 3000 | **4000** | 80/443 | +| api | 3001 | **4001** | (internal) | +| web-sm | 3002 | **4002** | (internal) | +| tui-sm | 3003 | **4003** | (internal) | +| PostgreSQL | 5432 | **5433** | (internal) | +| NATS | 4222 | **4223** | (internal) | +| MinIO | 9000 | **9002** | (internal) | +| Gitea | - | **4080** | - | + +Playwright connects to `http://localhost:4000` (auth-gw). + +## Test Credentials + +| Service | User / Endpoint | Credentials | +|------------|----------------------------------------|---------------------------| +| PostgreSQL | `localhost:5433 / ajet_chat_test` | `ajet` / `ajet_test` | +| NATS | `nats://localhost:4223` | (no auth) | +| MinIO | `localhost:9002` | `minioadmin` / `minioadmin` | +| Gitea | `localhost:4080` (admin) | `gitea-admin` / `gitea-admin-pass` | +| Gitea | `localhost:4080` (test user A) | `testuser-a` / `testpass-a` | +| Gitea | `localhost:4080` (test user B) | `testuser-b` / `testpass-b` | + +## Quick Start + +```bash +# From the repo root: +bb test:e2e:browser +``` + +This will: +1. Start all Docker containers (infra + app services + Gitea) +2. Install npm dependencies and Playwright browsers +3. Run the Playwright test suite +4. Tear down containers + +## Manual Steps + +```bash +# 1. Start the E2E Docker stack +docker compose -f docker-compose.test.yml --profile e2e up -d --build + +# 2. Install dependencies (first time only) +cd e2e +npm install +npx playwright install chromium + +# 3. Run tests +npx nbb -cp src -m ajet-chat.e2e.runner + +# 4. Stop the stack +docker compose -f docker-compose.test.yml --profile e2e down -v +``` + +## Debugging + +Run with visible browser: + +```bash +HEADLESS=false bb test:e2e:browser +``` + +Slow down actions (ms): + +```bash +SLOW_MO=500 HEADLESS=false bb test:e2e:browser +``` + +Screenshots are saved to `e2e/screenshots/` on test failure. + +## Test Workflows + +| File | Workflow | +|-----------------------|----------------------------------------------| +| `setup_test.cljs` | Setup wizard: add OAuth provider, first login, create community | +| `community_test.cljs` | Create additional communities | +| `channels_test.cljs` | Create channels, navigate between them | +| `messaging_test.cljs` | Two-user real-time chat via SSE | +| `dms_test.cljs` | Direct messages between two users | +| `upload_test.cljs` | File upload and cross-user visibility | + +## Environment Variables + +| Variable | Default | Description | +|-----------------------|--------------------------|--------------------------------| +| `AJET_TEST_BASE_URL` | `http://localhost:4000` | Auth gateway URL | +| `AJET_TEST_GITEA_URL` | `http://localhost:4080` | Gitea URL for API calls | +| `AJET_TEST_API_URL` | `http://localhost:4001` | Direct API URL | +| `HEADLESS` | `true` | Run browser headless | +| `SLOW_MO` | `0` | Slow down actions (ms) | diff --git a/e2e/entrypoint.sh b/e2e/entrypoint.sh new file mode 100755 index 0000000..adb2f21 --- /dev/null +++ b/e2e/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +HEALTH_URL="${AJET_TEST_BASE_URL:-http://auth-gw:3000}/health" +echo "Waiting for auth-gw at $HEALTH_URL..." + +attempts=0 +max_attempts=60 +until wget -qO- "$HEALTH_URL" 2>/dev/null | grep -q "ok"; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge "$max_attempts" ]; then + echo "ERROR: auth-gw did not become healthy after $max_attempts attempts" + exit 1 + fi + sleep 2 +done + +echo "Auth-gw is healthy. Running tests..." +exec "$@" diff --git a/e2e/nbb.edn b/e2e/nbb.edn new file mode 100644 index 0000000..ccd9a31 --- /dev/null +++ b/e2e/nbb.edn @@ -0,0 +1 @@ +{:paths ["src"]} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..266fa65 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,81 @@ +{ + "name": "ajet-chat-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ajet-chat-e2e", + "dependencies": { + "nbb": "^1.2.196", + "playwright": "^1.50.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/nbb": { + "version": "1.4.206", + "resolved": "https://registry.npmjs.org/nbb/-/nbb-1.4.206.tgz", + "integrity": "sha512-6UycYoLo4zBEiYrUIWLVvhTBvfdJQIWEvN2JAmvLscBivhEShFjbkw1Qfn7tFRJKXVtQoMVwlQ7ncwcD97yudQ==", + "license": "EPL-1.0", + "dependencies": { + "import-meta-resolve": "^4.1.0" + }, + "bin": { + "nbb": "cli.js", + "nbbun": "nbbun.js" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..0367426 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "ajet-chat-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "npx nbb -cp src -m ajet-chat.e2e.runner", + "install:browsers": "npx playwright install chromium" + }, + "dependencies": { + "nbb": "^1.2.196", + "playwright": "^1.50.0" + } +} diff --git a/e2e/resources/test-image.png b/e2e/resources/test-image.png new file mode 100644 index 0000000..e6f8ea4 Binary files /dev/null and b/e2e/resources/test-image.png differ diff --git a/e2e/screenshots/.gitkeep b/e2e/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/e2e/src/ajet_chat/e2e/config.cljs b/e2e/src/ajet_chat/e2e/config.cljs new file mode 100644 index 0000000..5c408d2 --- /dev/null +++ b/e2e/src/ajet_chat/e2e/config.cljs @@ -0,0 +1,62 @@ +(ns ajet-chat.e2e.config) + +;; --------------------------------------------------------------------------- +;; URLs — default to localhost test ports, override via env vars +;; --------------------------------------------------------------------------- + +(def base-url + "Auth gateway URL (entry point for all browser requests)." + (or js/process.env.AJET_TEST_BASE_URL "http://localhost:4000")) + +(def gitea-url + "Gitea URL for API calls (user creation, OAuth app setup)." + (or js/process.env.AJET_TEST_GITEA_URL "http://localhost:4080")) + +(def api-url + "Internal API URL (for direct API calls bypassing auth-gw)." + (or js/process.env.AJET_TEST_API_URL "http://localhost:4001")) + +;; --------------------------------------------------------------------------- +;; Database +;; --------------------------------------------------------------------------- + +(def db-config + {:host (or js/process.env.AJET_TEST_DB_HOST "localhost") + :port (or js/process.env.AJET_TEST_DB_PORT "5433") + :database (or js/process.env.AJET_TEST_DB_DBNAME "ajet_chat_test") + :user (or js/process.env.AJET_TEST_DB_USER "ajet") + :password (or js/process.env.AJET_TEST_DB_PASSWORD "ajet_test")}) + +;; --------------------------------------------------------------------------- +;; Gitea credentials +;; --------------------------------------------------------------------------- + +(def gitea-admin + {:username "gitea-admin" + :password "gitea-admin-pass"}) + +(def test-user-a + {:username "testuser-a" + :password "testpass-a" + :email "testuser-a@test.local"}) + +(def test-user-b + {:username "testuser-b" + :password "testpass-b" + :email "testuser-b@test.local"}) + +;; --------------------------------------------------------------------------- +;; Playwright +;; --------------------------------------------------------------------------- + +(def headless? + "Set HEADLESS=false to run browser visibly for debugging." + (not= "false" (or js/process.env.HEADLESS "true"))) + +(def slow-mo + "Milliseconds to slow down Playwright operations (useful for debugging)." + (js/parseInt (or js/process.env.SLOW_MO "0") 10)) + +(def default-timeout + "Default timeout for waitFor operations (ms)." + 15000) diff --git a/e2e/src/ajet_chat/e2e/debug.cljs b/e2e/src/ajet_chat/e2e/debug.cljs new file mode 100644 index 0000000..7de0b46 --- /dev/null +++ b/e2e/src/ajet_chat/e2e/debug.cljs @@ -0,0 +1,45 @@ +(ns ajet-chat.e2e.debug + (:require [ajet-chat.e2e.setup :as setup] + [ajet-chat.e2e.helpers :as h] + [ajet-chat.e2e.config :as config] + [promesa.core :as p])) + +(-> (p/let [_ (setup/setup-gitea-and-provider!) + browser (h/launch-browser!) + context (h/new-context! browser) + page (h/new-page! context)] + (p/do + (setup/login-via-gitea! page config/test-user-a) + (h/wait-for-url page #"/(app|setup)" {:timeout 30000}) + (js/console.log "After login URL:" (h/page-url page)) + + (h/goto page "/setup") + (h/wait-ms 2000) + (js/console.log "On /setup URL:" (h/page-url page)) + (h/screenshot! page "debug-setup-page.png") + + ;; Check page HTML for form type + (p/let [form-html (h/evaluate page "document.querySelector('form')?.outerHTML?.substring(0, 200)")] + (js/console.log "Form HTML:" form-html)) + + (h/wait-for-selector page "input[name='name']") + (h/wait-ms 1500) + (h/type-text page "input[name='name']" "Debug Community") + (h/type-text page "input[name='slug']" "debug-community") + (h/screenshot! page "debug-filled-form.png") + + ;; Click submit + (h/click page "button[type='submit']") + (h/wait-ms 5000) + (js/console.log "After submit URL:" (h/page-url page)) + (h/screenshot! page "debug-after-submit.png") + + ;; Check for any error message on page + (p/let [error-text (p/catch + (h/text-content page "#setup-error") + (fn [_] nil))] + (js/console.log "Setup error:" error-text)) + + (h/close-all! browser))) + (.catch (fn [e] (js/console.error "Error:" (.-message e)))) + (.then (fn [_] (js/process.exit 0)))) diff --git a/e2e/src/ajet_chat/e2e/debug_msg.cljs b/e2e/src/ajet_chat/e2e/debug_msg.cljs new file mode 100644 index 0000000..363c14f --- /dev/null +++ b/e2e/src/ajet_chat/e2e/debug_msg.cljs @@ -0,0 +1,85 @@ +(ns ajet-chat.e2e.debug-msg + (:require [ajet-chat.e2e.setup :as setup] + [ajet-chat.e2e.helpers :as h] + [ajet-chat.e2e.config :as config] + [promesa.core :as p])) + +(-> (p/let [oauth-creds (setup/setup-gitea-and-provider!) + browser (h/launch-browser!) + context (h/new-context! browser) + page (h/new-page! context)] + (p/do + ;; Capture ALL network requests + (.on page "request" (fn [req] + (let [url (.url req)] + (when (or (re-find #"/sse" url) + (re-find #"/web/" url) + (re-find #"/api/" url)) + (js/console.log "[req]" (.method req) url))))) + (.on page "response" (fn [resp] + (let [url (.url resp)] + (when (or (re-find #"/sse" url) + (re-find #"/web/" url)) + (js/console.log "[resp]" (.status resp) url + "headers:" (js/JSON.stringify (.headers resp))))))) + (.on page "console" (fn [msg] + (when (= (.type msg) "error") + (js/console.log "[browser error]" (.text msg))))) + + ;; Full setup wizard + (h/goto page "/") + (h/wait-for-url page #"/setup" {:timeout 10000}) + (h/wait-for-selector page "select[name='provider-type']") + (h/select-option page "select[name='provider-type']" "gitea") + (h/fill page "input[name='display-name']" "Gitea Test") + (h/fill page "input[name='slug']" "gitea-test") + (h/fill page "input[name='client-id']" (:client-id oauth-creds)) + (h/fill page "input[name='client-secret']" (:client-secret oauth-creds)) + (h/fill page "input[name='base-url']" config/gitea-url) + (h/click page "button:has-text('Add Provider')") + (h/wait-for-url page #"/auth/login" {:timeout 10000}) + (h/click page "a[href*='provider=gitea-test']") + (h/wait-for-selector page "#user_name" {:timeout 15000}) + (h/fill page "#user_name" (:username config/test-user-a)) + (h/fill page "#password" (:password config/test-user-a)) + (h/click page "button:has-text('Sign In')") + (p/let [auth-btn (p/catch + (.waitForSelector page "button:has-text('Authorize Application'), button.ui.green.button" + #js {:timeout 5000}) + (fn [_] nil))] + (when auth-btn (.click auth-btn))) + (h/wait-for-url page #"/(setup|app)" {:timeout 30000}) + (p/let [url (h/page-url page)] + (when (re-find #"/setup" url) + (p/do + (h/wait-for-selector page "input[name='name']") + (h/fill page "input[name='name']" "Debug Community") + (h/fill page "input[name='slug']" "debug-community") + (h/click page "button[type='submit']") + (h/wait-ms 2000)))) + (h/wait-for-url page #"/app" {:timeout 15000}) + (js/console.log "\n=== ON /app — waiting for SSE activity ===") + + ;; Wait 8 seconds to see if SSE connection is made + (h/wait-ms 8000) + + ;; Check directly — make a manual fetch to /sse/events + (js/console.log "\n=== Manual SSE test ===") + (p/let [sse-test (h/evaluate page + "new Promise((resolve) => { + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), 3000); + fetch('/sse/events', {signal: ctrl.signal}) + .then(r => resolve({status: r.status, ok: r.ok, headers: Object.fromEntries(r.headers.entries())})) + .catch(e => resolve({error: e.message})); + })")] + (js/console.log "SSE fetch result:" (js/JSON.stringify (clj->js sse-test)))) + + ;; Also check /web/health + (p/let [health (h/evaluate page "fetch('/web/health').then(r => r.text())")] + (js/console.log "Web-SM health:" health)) + + (h/close-all! browser))) + (.catch (fn [e] + (js/console.error "Error:" (.-message e)))) + (.then (fn [_] (js/process.exit 0)))) diff --git a/e2e/src/ajet_chat/e2e/debug_route.cljs b/e2e/src/ajet_chat/e2e/debug_route.cljs new file mode 100644 index 0000000..8465557 --- /dev/null +++ b/e2e/src/ajet_chat/e2e/debug_route.cljs @@ -0,0 +1,87 @@ +(ns ajet-chat.e2e.debug-route + "Debug script to test OAuth flow with localhost URLs." + (:require ["playwright" :as pw] + [ajet-chat.e2e.config :as config] + [ajet-chat.e2e.helpers :as h] + [ajet-chat.e2e.setup :as setup] + [promesa.core :as p])) + +(defn -main [& _args] + (p/let [;; Setup Gitea + OAuth + oauth-creds (setup/setup-gitea-and-provider!) + _ (js/console.log "[debug] OAuth creds:" (clj->js oauth-creds)) + + ;; Launch browser + browser (h/launch-browser!) + context (h/new-context! browser) + page (h/new-page! context)] + + (-> (p/do + ;; Log requests for debugging + (.on page "request" (fn [req] + (js/console.log (str "[req] " (.method req) " " (.url req))))) + (.on page "requestfailed" (fn [req] + (js/console.log (str "[FAIL] " (.url req) " - " + (.-errorText (.failure req)))))) + + ;; Navigate to setup + (h/goto page "/") + (js/console.log "[debug] URL after goto /:" (.url page)) + + (h/wait-for-url page #"/setup" {:timeout 10000}) + + ;; Fill provider form with localhost:4080 URL + (h/wait-for-selector page "select[name='provider-type']") + (h/select-option page "select[name='provider-type']" "gitea") + (h/fill page "input[name='display-name']" "Gitea Test") + (h/fill page "input[name='slug']" "gitea-test") + (h/fill page "input[name='client-id']" (:client-id oauth-creds)) + (h/fill page "input[name='client-secret']" (:client-secret oauth-creds)) + (h/fill page "input[name='base-url']" config/gitea-url) + (js/console.log "[debug] Form filled with base-url:" config/gitea-url) + + (h/click page "button:has-text('Add Provider')") + (h/wait-for-url page #"/auth/login" {:timeout 10000}) + (js/console.log "[debug] On login page:" (.url page)) + + ;; Click Gitea provider + (h/wait-for-selector page "a[href*='provider=gitea-test']") + (h/click page "a[href*='provider=gitea-test']") + + ;; Wait for Gitea login form + (h/wait-for-selector page "#user_name, input[name='user_name']" + {:timeout 15000}) + (js/console.log "[debug] On Gitea login form! URL:" (.url page)) + (h/screenshot! page "debug-gitea-login.png") + + ;; Fill Gitea login + (h/fill page "#user_name" (:username config/test-user-a)) + (h/fill page "#password" (:password config/test-user-a)) + (h/click page "button:has-text('Sign In')") + (js/console.log "[debug] Submitted Gitea login, waiting...") + + ;; Handle OAuth authorization page + (p/let [auth-btn (p/catch + (.waitForSelector page + "button:has-text('Authorize Application'), button.ui.green.button" + #js {:timeout 5000}) + (fn [_] nil))] + (when auth-btn + (js/console.log "[debug] Found authorize button, clicking...") + (.click auth-btn))) + + ;; Wait for redirect back to ajet-chat + (h/wait-for-url page #"localhost:4000" {:timeout 30000}) + (js/console.log "[debug] Back on ajet-chat! URL:" (.url page)) + (h/screenshot! page "debug-back-on-app.png") + + (js/console.log "[debug] SUCCESS!")) + + (p/catch (fn [err] + (p/do + (js/console.error "[debug] Error:" (.-message err)) + (js/console.log "[debug] URL at error:" (.url page)) + (h/screenshot! page "debug-error.png")))) + (p/finally (fn [] + (h/close-all! browser) + (js/console.log "[debug] Browser closed")))))) diff --git a/e2e/src/ajet_chat/e2e/debug_sse.cljs b/e2e/src/ajet_chat/e2e/debug_sse.cljs new file mode 100644 index 0000000..e4960c8 --- /dev/null +++ b/e2e/src/ajet_chat/e2e/debug_sse.cljs @@ -0,0 +1,139 @@ +(ns ajet-chat.e2e.debug-sse + (:require [ajet-chat.e2e.setup :as setup] + [ajet-chat.e2e.helpers :as h] + [ajet-chat.e2e.config :as config] + [promesa.core :as p])) + +(-> (p/let [oauth-creds (setup/setup-gitea-and-provider!) + browser (h/launch-browser!) + context (h/new-context! browser) + page (h/new-page! context)] + (p/do + ;; Full setup wizard (add provider + create community) + (h/goto page "/") + (h/wait-for-url page #"/setup" {:timeout 10000}) + (h/wait-for-selector page "select[name='provider-type']") + (h/select-option page "select[name='provider-type']" "gitea") + (h/fill page "input[name='display-name']" "Gitea Test") + (h/fill page "input[name='slug']" "gitea-test") + (h/fill page "input[name='client-id']" (:client-id oauth-creds)) + (h/fill page "input[name='client-secret']" (:client-secret oauth-creds)) + (h/fill page "input[name='base-url']" config/gitea-url) + (h/click page "button:has-text('Add Provider')") + (h/wait-for-url page #"/auth/login" {:timeout 10000}) + + ;; Login via Gitea + (h/click page "a[href*='provider=gitea-test']") + (h/wait-for-selector page "#user_name" {:timeout 15000}) + (h/fill page "#user_name" (:username config/test-user-a)) + (h/fill page "#password" (:password config/test-user-a)) + (h/click page "button:has-text('Sign In')") + (p/let [auth-btn (p/catch + (.waitForSelector page "button:has-text('Authorize Application'), button.ui.green.button" + #js {:timeout 5000}) + (fn [_] nil))] + (when auth-btn (.click auth-btn))) + (h/wait-for-url page #"/(setup|app)" {:timeout 30000}) + + ;; Create community if on /setup + (p/let [url (h/page-url page)] + (when (re-find #"/setup" url) + (p/do + (h/wait-for-selector page "input[name='name']") + (h/wait-ms 1500) + (h/type-text page "input[name='name']" "Debug SSE Community") + (h/type-text page "input[name='slug']" "debug-sse") + (h/click page "button[type='submit']") + (h/wait-ms 2000)))) + (h/wait-for-url page #"/app" {:timeout 15000}) + (js/console.log "\n=== On /app ===") + + ;; Wait for page to fully load including Datastar SDK + (h/wait-ms 5000) + + ;; Check 1: Is Datastar loaded? + (p/let [ds-check (h/evaluate page + "(() => { + const results = {}; + results.hasDatastarAttr = !!document.querySelector('[data-signals]'); + results.moduleScripts = Array.from(document.querySelectorAll('script[type=\"module\"]')).map(s => s.src); + results.title = document.title; + results.hasEventSource = typeof EventSource !== 'undefined'; + results.resourceEntries = performance.getEntriesByType('resource') + .filter(r => r.name.includes('sse') || r.name.includes('datastar') || r.name.includes('tailwind')) + .map(r => ({name: r.name.split('/').pop(), duration: Math.round(r.duration), status: r.responseStatus})); + return results; + })()")] + (js/console.log "Check 1 - Datastar/CDN:" (js/JSON.stringify (clj->js ds-check) nil 2))) + + ;; Check 2: App state element + (p/let [app-state (h/evaluate page + "(() => { + const el = document.getElementById('app-state'); + if (!el) return {found: false}; + return { + found: true, + dataOnLoad: el.getAttribute('data-on-load'), + children: el.childElementCount + }; + })()")] + (js/console.log "Check 2 - App state:" (js/JSON.stringify (clj->js app-state)))) + + ;; Check 3: SSE manual test + (p/let [sse-test (h/evaluate page + "new Promise((resolve) => { + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), 5000); + fetch('/sse/events', {signal: ctrl.signal, headers: {'Accept': 'text/event-stream'}}) + .then(r => { + const reader = r.body.getReader(); + const decoder = new TextDecoder(); + let data = ''; + function read() { + reader.read().then(({done, value}) => { + if (done) { resolve({status: r.status, data}); return; } + data += decoder.decode(value, {stream: true}); + if (data.length > 500) { ctrl.abort(); resolve({status: r.status, data: data.substring(0, 500)}); return; } + read(); + }).catch(e => resolve({status: r.status, data, aborted: true})); + } + read(); + }) + .catch(e => resolve({error: e.message})); + })")] + (js/console.log "Check 3 - SSE manual:" (js/JSON.stringify (clj->js sse-test) nil 2))) + + ;; Check 4: Send a message and see if it appears + (p/let [ch-el (.waitForSelector page "[id^='sidebar-channel-']" + #js {:timeout 5000}) + ch-el-id (when ch-el (.getAttribute ch-el "id")) + channel-id (.replace ch-el-id "sidebar-channel-" "")] + (js/console.log "\nChannel ID:" channel-id) + + ;; Send a message + (p/let [send-result (h/send-message! page channel-id "Debug SSE test message")] + (js/console.log "Send result:" (js/JSON.stringify (clj->js send-result)))) + + ;; Wait for message to appear via SSE + (p/let [found (p/catch + (p/do + (h/wait-for-text page "Debug SSE test message" {:timeout 10000}) + true) + (fn [_] false))] + (js/console.log "Message visible via SSE:" found)) + + ;; If not found, try page reload + (p/let [_ (h/goto page "/app") + _ (h/wait-for-url page #"/app" {:timeout 5000}) + _ (h/wait-ms 2000) + found-reload (p/catch + (p/do + (h/wait-for-text page "Debug SSE test message" {:timeout 10000}) + true) + (fn [_] false))] + (js/console.log "Message visible after reload:" found-reload))) + + (h/close-all! browser))) + (.catch (fn [e] + (js/console.error "Error:" (.-message e)))) + (.then (fn [_] (js/process.exit 0)))) diff --git a/e2e/src/ajet_chat/e2e/helpers.cljs b/e2e/src/ajet_chat/e2e/helpers.cljs new file mode 100644 index 0000000..7f8c360 --- /dev/null +++ b/e2e/src/ajet_chat/e2e/helpers.cljs @@ -0,0 +1,235 @@ +(ns ajet-chat.e2e.helpers + (:require ["playwright" :as pw] + [ajet-chat.e2e.config :as config] + [promesa.core :as p])) + +;; --------------------------------------------------------------------------- +;; Browser lifecycle +;; --------------------------------------------------------------------------- + +(defn launch-browser! + "Launch a Chromium browser instance. Returns a promise." + [] + (.launch pw/chromium + #js {:headless config/headless? + :slowMo config/slow-mo})) + +(defn new-context! + "Create a new browser context (isolated cookies/storage). Returns a promise." + [browser] + (.newContext browser)) + +(defn new-page! + "Create a new page within a browser context. Returns a promise." + [context] + (.newPage context)) + +(defn close-all! + "Close browser and all contexts." + [browser] + (when browser + (.close browser))) + +;; --------------------------------------------------------------------------- +;; Navigation +;; --------------------------------------------------------------------------- + +(defn goto + "Navigate to a URL (relative to base-url or absolute). Returns a promise." + ([page path] + (goto page path {})) + ([page path opts] + (let [url (if (re-find #"^https?://" path) + path + (str config/base-url path)) + wait-until (or (:wait-until opts) "load")] + (.goto page url #js {:waitUntil wait-until + :timeout (or (:timeout opts) 30000)})))) + +;; --------------------------------------------------------------------------- +;; Interactions +;; --------------------------------------------------------------------------- + +(defn click + "Click an element matching the selector." + ([page selector] + (click page selector {})) + ([page selector opts] + (.click page selector (clj->js (merge {:timeout config/default-timeout} opts))))) + +(defn fill + "Fill a form field matching the selector." + [page selector value] + (.fill page selector value #js {:timeout config/default-timeout})) + +(defn type-text + "Type text character by character (triggers key handlers)." + ([page selector text] + (type-text page selector text {})) + ([page selector text opts] + (.type page selector text (clj->js (merge {:timeout config/default-timeout} opts))))) + +(defn press + "Press a key (e.g. 'Enter', 'Tab')." + [page selector key-name] + (.press page selector key-name #js {:timeout config/default-timeout})) + +(defn select-option + "Select an option in a