init codebase
This commit is contained in:
+13
-8
@@ -6,15 +6,16 @@ Product Requirements Documents for ajet-chat v1.
|
||||
|
||||
| Module | PRD | Test Cases | Status |
|
||||
|--------|-----|------------|--------|
|
||||
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown | 60 | v1 |
|
||||
| [api](api.md) | REST API: 15 endpoint groups, full CRUD | 95 | v1 |
|
||||
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy | 40 | v1 |
|
||||
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout | 57 | v1 |
|
||||
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events | 30 | v1 |
|
||||
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui) | 50 | v1 |
|
||||
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown, Config, Logging, Storage | 92 | v1 |
|
||||
| [api](api.md) | REST API: 15 endpoint groups, full CRUD, health check, ban/mute | 129 | v1 |
|
||||
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy, CORS, audit logging | 40 | v1 |
|
||||
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout, emoji picker, toasts | 57 | v1 |
|
||||
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events, backpressure | 30 | v1 |
|
||||
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui), bbin packaging, exit codes | 79 | v1 |
|
||||
| [infrastructure](infrastructure.md) | Docker Compose (dev/test/prod), NATS JetStream, MinIO, nginx | 10 | v1 |
|
||||
| [mobile](mobile.md) | Deferred — PWA recommended for v1 | 0 | v2+ |
|
||||
|
||||
**Total test cases: ~332**
|
||||
**Total test cases: 437**
|
||||
|
||||
## Key Product Decisions
|
||||
|
||||
@@ -26,7 +27,9 @@ Product Requirements Documents for ajet-chat v1.
|
||||
- **Mention storage** — `@<user:uuid>` / `@<here>` / `#<channel:uuid>` in DB, rendered at display time
|
||||
- **1-hour edit window** — messages can only be edited within 1 hour of creation
|
||||
- **Images + paste** — clipboard paste and upload, no arbitrary file types in v1
|
||||
- **OAuth-only auth** — GitHub + Gitea + generic OIDC (for self-hosters)
|
||||
- **OAuth auth** — GitHub + Gitea + generic OIDC (future: email-based auth via magic link/OTP)
|
||||
- **DB-stored OAuth providers** — OAuth provider config stored in `oauth_providers` table, manageable at runtime via admin API
|
||||
- **Admin setup wizard** — multi-step first-deployment bootstrap: configure providers, admin OAuth login, create first community
|
||||
- **3-tier roles** — Owner / Admin / Member (no custom roles in v1)
|
||||
- **Invite links + direct invites** — admins generate links or invite by user ID
|
||||
- **Incoming webhooks** — external services POST to channel (outgoing deferred)
|
||||
@@ -36,3 +39,5 @@ Product Requirements Documents for ajet-chat v1.
|
||||
- **Paginated messages** — "Load older" button, cursor-based pagination
|
||||
- **Rich TUI** — split panes, inline images (timg/sixel), markdown rendering, mouse support
|
||||
- **Full CLI** — all operations scriptable, JSON output, stdin piping
|
||||
- **Tailwind CSS** — dark theme, utility-first styling in Hiccup
|
||||
- **Ban/mute enforcement** — bans permanent until lifted, mutes time-limited with auto-expiry
|
||||
|
||||
+186
@@ -47,6 +47,10 @@ Auth Gateway → API Service → PostgreSQL
|
||||
016-create-webhooks.up.sql
|
||||
017-create-invites.up.sql
|
||||
018-add-search-indexes.up.sql
|
||||
019-create-bans.up.sql
|
||||
020-create-mutes.up.sql
|
||||
021-create-oauth-providers.up.sql
|
||||
022-create-system-settings.up.sql
|
||||
```
|
||||
|
||||
### 3.2 Tables
|
||||
@@ -75,8 +79,41 @@ idx_messages_search ON messages USING GIN(to_tsvector('english', body_md))
|
||||
idx_notifications_user_unread ON notifications(user_id, created_at) WHERE read = false
|
||||
idx_channel_members_user ON channel_members(user_id)
|
||||
idx_community_members_user ON community_members(user_id)
|
||||
|
||||
-- Ban/mute enforcement
|
||||
bans (community_id uuid FK, user_id uuid FK, reason text, banned_by uuid FK, created_at timestamptz, PK(community_id, user_id))
|
||||
mutes (community_id uuid FK, user_id uuid FK, expires_at timestamptz, muted_by uuid FK, created_at timestamptz, PK(community_id, user_id))
|
||||
idx_mutes_expires ON mutes(expires_at) WHERE expires_at IS NOT NULL
|
||||
|
||||
-- OAuth providers (runtime-configurable)
|
||||
oauth_providers (id uuid PK, provider_type text CHECK(github/gitea/oidc), name text, client_id text, client_secret_encrypted text, base_url text NULL, issuer_url text NULL, enabled boolean DEFAULT true, created_at timestamptz, updated_at timestamptz)
|
||||
idx_oauth_providers_type ON oauth_providers(provider_type)
|
||||
|
||||
-- System settings (key-value for deployment-wide config)
|
||||
system_settings (key text PK, value jsonb, updated_at timestamptz)
|
||||
```
|
||||
|
||||
### 3.3 Ban & Mute Enforcement
|
||||
|
||||
**Bans:**
|
||||
- Ban record in `bans` table prevents user from:
|
||||
- Sending messages in any channel of the community
|
||||
- Joining channels
|
||||
- Accepting invites to the community
|
||||
- Banned user is removed from all channels and community membership on ban
|
||||
- Ban check runs in middleware for all community-scoped API endpoints
|
||||
- Bans are permanent until explicitly lifted by Admin+
|
||||
|
||||
**Mutes:**
|
||||
- Mute record in `mutes` table with `expires_at` timestamp
|
||||
- Muted user cannot:
|
||||
- Send messages (POST to message endpoints returns 403)
|
||||
- Add reactions
|
||||
- Send typing indicators
|
||||
- Muted user CAN still read messages and channels
|
||||
- Expired mutes are ignored (no cleanup needed — checked on read)
|
||||
- Duration specified as interval: `10m`, `1h`, `24h`, `7d`
|
||||
|
||||
## 4. API Endpoints
|
||||
|
||||
### 4.1 Communities
|
||||
@@ -590,6 +627,43 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
|
||||
|
||||
---
|
||||
|
||||
### 4.16 Admin: OAuth Provider Management
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/api/admin/oauth-providers` | List all OAuth providers | Owner |
|
||||
| POST | `/api/admin/oauth-providers` | Create OAuth provider | Owner |
|
||||
| PUT | `/api/admin/oauth-providers/:id` | Update OAuth provider | Owner |
|
||||
| DELETE | `/api/admin/oauth-providers/:id` | Delete OAuth provider | Owner |
|
||||
|
||||
**POST /api/admin/oauth-providers**
|
||||
```
|
||||
Request: {"provider_type": "github", "name": "GitHub", "client_id": "...", "client_secret": "...", "enabled": true}
|
||||
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub", "client_id": "...", "enabled": true, "created_at": "..."}
|
||||
Note: client_secret is encrypted at rest and never returned in responses.
|
||||
```
|
||||
|
||||
**PUT /api/admin/oauth-providers/:id**
|
||||
```
|
||||
Request: {"name": "GitHub Org", "client_id": "...", "client_secret": "...", "enabled": false}
|
||||
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub Org", ...}
|
||||
Note: Omitting client_secret from the request leaves it unchanged.
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AOP-T1 | Create OAuth provider | POST creates provider, returns without client_secret |
|
||||
| AOP-T2 | List providers | GET returns all providers without secrets |
|
||||
| AOP-T3 | Update provider | PUT updates name/enabled, secret unchanged if omitted |
|
||||
| AOP-T4 | Delete provider | DELETE removes provider |
|
||||
| AOP-T5 | Non-owner access | Non-owner user returns 403 |
|
||||
| AOP-T6 | Invalid provider_type | POST with unknown type returns 422 |
|
||||
| AOP-T7 | Duplicate provider_type | Allowed (multiple GitHub providers for different orgs) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Error Response Format
|
||||
@@ -609,3 +683,115 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
|
||||
### 5.4 Audit Trail (P2)
|
||||
- Log all admin actions (kick, ban, role change, channel delete) with actor + target + timestamp
|
||||
- Queryable by Owner via future admin API
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Configuration
|
||||
|
||||
### 6.1 Config Shape
|
||||
|
||||
```clojure
|
||||
{:server {:host "0.0.0.0" :port 3001}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "..." :pool-size 10
|
||||
:migrations {:enabled true :location "migrations"}}
|
||||
:nats {:url "nats://localhost:4222"
|
||||
:stream-name "ajet-events"
|
||||
:publish-timeout-ms 5000}
|
||||
:minio {:endpoint "http://localhost:9000"
|
||||
:access-key "minioadmin" :secret-key "minioadmin"
|
||||
:bucket "ajet-chat"}
|
||||
:limits {:max-message-length 4000 ;; characters
|
||||
:max-upload-size 10485760 ;; 10MB in bytes
|
||||
:edit-window-minutes 60
|
||||
:default-page-size 50
|
||||
:max-page-size 100}}
|
||||
```
|
||||
|
||||
### 6.2 Middleware Pipeline
|
||||
|
||||
Requests flow through middleware in this order:
|
||||
|
||||
```
|
||||
1. Ring defaults (params, cookies, multipart)
|
||||
2. Request logging (method, path, start time)
|
||||
3. Exception handler (catch-all → 500 JSON error)
|
||||
4. Trace ID extraction (X-Trace-Id → MDC)
|
||||
5. User context extraction (X-User-Id, X-User-Role, X-Community-Id → request map)
|
||||
6. Ban check (community-scoped: reject if user is banned)
|
||||
7. Mute check (write endpoints: reject if user is muted)
|
||||
8. Reitit routing → handler
|
||||
9. Response logging (status, duration)
|
||||
```
|
||||
|
||||
### 6.3 Startup / Shutdown Sequence
|
||||
|
||||
**Startup:**
|
||||
```
|
||||
1. Load config (EDN + env vars)
|
||||
2. Create DB connection pool (HikariCP)
|
||||
3. Run Migratus migrations (if enabled)
|
||||
4. Connect to NATS
|
||||
5. Connect to MinIO, ensure bucket exists
|
||||
6. Start http-kit server
|
||||
7. Log "API service started on port {port}"
|
||||
```
|
||||
|
||||
**Shutdown (graceful):**
|
||||
```
|
||||
1. Stop accepting new HTTP connections
|
||||
2. Wait for in-flight requests (max 30s)
|
||||
3. Close NATS connection
|
||||
4. Close DB connection pool
|
||||
5. Log "API service stopped"
|
||||
```
|
||||
|
||||
### 6.4 Health Check
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/health` | None | Service health status |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{"status": "ok", "checks": {"db": "ok", "nats": "ok", "minio": "ok"}}
|
||||
```
|
||||
|
||||
**Response (503 — degraded):**
|
||||
```json
|
||||
{"status": "degraded", "checks": {"db": "ok", "nats": "error", "minio": "ok"}}
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| HLT-T1 | Health check all up | Returns 200 with all checks "ok" |
|
||||
| HLT-T2 | Health check DB down | Returns 503 with db check "error" |
|
||||
| HLT-T3 | Health check NATS down | Returns 503 with nats check "error" |
|
||||
| HLT-T4 | Health check MinIO down | Returns 503 with minio check "error" |
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration SQL (Outlines)
|
||||
|
||||
### 7.1 Migration Naming Convention
|
||||
|
||||
```
|
||||
{NNN}-{description}.up.sql — forward migration
|
||||
{NNN}-{description}.down.sql — rollback migration
|
||||
```
|
||||
|
||||
Migrations are sequential and must be applied in order. Each migration is idempotent — re-running a completed migration is a no-op (handled by Migratus tracking table).
|
||||
|
||||
### 7.2 Key Migration Notes
|
||||
|
||||
- **001-create-users:** `users` table + unique index on `username` + unique index on `email`
|
||||
- **006-create-channels:** `channels` table, `community_id` is nullable (DMs have NULL)
|
||||
- **008-create-messages:** `messages` table + composite index `(channel_id, created_at)` for pagination
|
||||
- **010-create-reactions:** Composite PK `(message_id, user_id, emoji)` — one reaction per user per emoji per message
|
||||
- **018-add-search-indexes:** `to_tsvector('english', body_md)` GIN index on `messages` for full-text search
|
||||
- **019-create-bans:** `bans` table with composite PK `(community_id, user_id)`
|
||||
- **020-create-mutes:** `mutes` table with composite PK `(community_id, user_id)`, index on `expires_at`
|
||||
- **021-create-oauth-providers:** `oauth_providers` table for runtime-configurable OAuth providers
|
||||
- **022-create-system-settings:** `system_settings` key-value table for deployment-wide settings (e.g., `setup_completed`)
|
||||
|
||||
+181
-31
@@ -31,9 +31,13 @@ Client → (nginx TLS, prod) → Auth Gateway → API Service
|
||||
| `GET /tui/sse/*` | TUI SM | Session | SSE streams for TUI clients |
|
||||
| `POST /tui/*` | TUI SM | Session | TUI client signals |
|
||||
| `POST /api/webhooks/*/incoming` | API | Webhook Token | Incoming webhooks (bypass session auth) |
|
||||
| `GET /auth/login` | Self | None | Login page |
|
||||
| `GET /auth/login` | Self | None | Login page (OAuth provider buttons) |
|
||||
| `GET /auth/callback/:provider` | Self | None | OAuth callback |
|
||||
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
|
||||
| `GET /setup/providers` | Self | None | Setup wizard step 1: configure OAuth providers |
|
||||
| `POST /setup/providers` | Self | None | Setup wizard step 1: save OAuth provider config |
|
||||
| `GET /setup/community` | Self | Session | Setup wizard step 3: create first community |
|
||||
| `POST /setup/community` | Self | Session | Setup wizard step 3: submit community creation |
|
||||
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
|
||||
| `GET /health` | Self | None | Health check |
|
||||
|
||||
@@ -75,20 +79,23 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
**Supported providers:** GitHub, Gitea, Generic OIDC
|
||||
|
||||
**Provider storage:** OAuth providers are stored in the `oauth_providers` DB table and are configurable at runtime via admin endpoints (`/api/admin/oauth-providers`). On first startup, if the `oauth_providers` table is empty, any providers configured via env vars (`:oauth` config) are auto-migrated to the DB.
|
||||
|
||||
```
|
||||
1. User visits /auth/login
|
||||
2. Page shows provider buttons (GitHub, Gitea, or configured OIDC)
|
||||
3. User clicks provider → redirect to provider's authorize URL
|
||||
4. Provider redirects to /auth/callback/:provider with code
|
||||
5. Auth GW exchanges code for access token
|
||||
6. Auth GW fetches user profile from provider
|
||||
7. Look up oauth_accounts by (provider, provider_user_id):
|
||||
2. Auth GW loads enabled providers from oauth_providers table
|
||||
3. Page shows provider buttons for each enabled provider
|
||||
4. User clicks provider → redirect to provider's authorize URL
|
||||
5. Provider redirects to /auth/callback/:provider with code
|
||||
6. Auth GW exchanges code for access token
|
||||
7. Auth GW fetches user profile from provider
|
||||
8. Look up oauth_accounts by (provider, provider_user_id):
|
||||
a. EXISTS: load user, create session
|
||||
b. NOT EXISTS: create user + oauth_account, create session
|
||||
8. Set session cookie, redirect to / (or to pending invite if present)
|
||||
9. Set session cookie, redirect to / (or to pending invite if present)
|
||||
```
|
||||
|
||||
**OAuth config shape:**
|
||||
**OAuth config shape (fallback — auto-migrated to DB on first startup):**
|
||||
```clojure
|
||||
{:oauth
|
||||
{:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
@@ -96,23 +103,39 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
|
||||
```
|
||||
|
||||
After migration, provider config is read exclusively from the DB. The `:oauth` config key serves only as a seed for initial deployment and is ignored once providers exist in the DB.
|
||||
|
||||
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
|
||||
|
||||
### 4.4 First-User Bootstrap
|
||||
### 4.4 Admin Setup Wizard (First-Deployment Bootstrap)
|
||||
|
||||
The setup wizard is a multi-step flow handled by Auth GW for first-time deployment. It activates when the `system_settings` table indicates setup is incomplete (no `setup_completed` flag).
|
||||
|
||||
```
|
||||
1. User hits /auth/login
|
||||
2. Auth GW checks: any users in DB?
|
||||
- No users: show "Create your community" flow after OAuth
|
||||
- Has users: normal login
|
||||
3. After first OAuth login:
|
||||
a. Create user from OAuth profile
|
||||
b. Redirect to /setup (community creation wizard on Web SM)
|
||||
c. Web SM shows: community name input, slug auto-generated
|
||||
d. POST creates community (user becomes owner, #general created)
|
||||
e. Redirect to /app
|
||||
Step 1 — Configure OAuth Providers (no auth required):
|
||||
1. User hits any route on a fresh deployment
|
||||
2. Auth GW checks system_settings: setup_completed?
|
||||
- Not completed: redirect to /setup/providers
|
||||
- Completed: normal login flow
|
||||
3. /setup/providers shows a form to configure at least one OAuth provider
|
||||
(provider type, client ID, client secret, base URL / issuer URL)
|
||||
4. Admin submits provider config → saved to oauth_providers table
|
||||
5. Auth GW redirects to /auth/login with the newly configured provider(s)
|
||||
|
||||
Step 2 — Admin Login via OAuth:
|
||||
6. Admin logs in via one of the just-configured OAuth providers
|
||||
7. First user is created with admin/owner privileges
|
||||
|
||||
Step 3 — Create First Community:
|
||||
8. After login, redirect to /setup/community (rendered by Auth GW, not Web SM)
|
||||
9. Admin enters community name, slug auto-generated
|
||||
10. POST creates community (user becomes owner, #general created)
|
||||
11. system_settings.setup_completed = true
|
||||
12. Redirect to /app
|
||||
```
|
||||
|
||||
Subsequent community creation (by already-authenticated users) uses the Web SM `/setup` page.
|
||||
|
||||
### 4.5 Invite Flow
|
||||
|
||||
```
|
||||
@@ -149,6 +172,7 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
| Endpoint Pattern | Limit | Window |
|
||||
|-----------------|-------|--------|
|
||||
| `POST /auth/login` | 10 | 1 min per IP |
|
||||
| `POST /auth/callback/*` | 10 | 1 min per IP |
|
||||
| `POST /api/*` | 60 | 1 min per user |
|
||||
| `GET /api/*` | 120 | 1 min per user |
|
||||
@@ -205,13 +229,18 @@ Attributes:
|
||||
| AUTH-T17 | New user first login | Creates user + oauth_account + session |
|
||||
| AUTH-T18 | OAuth state parameter | CSRF protection via state param validated on callback |
|
||||
|
||||
### 8.4 First-User Bootstrap
|
||||
### 8.4 Admin Setup Wizard
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T19 | Empty DB shows setup flow | /auth/login with 0 users shows setup message |
|
||||
| AUTH-T20 | First user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T21 | Subsequent users see normal login | With users in DB, normal login page shown |
|
||||
| AUTH-T19 | Fresh deploy redirects to setup | Any route with setup_completed=false redirects to /setup/providers |
|
||||
| AUTH-T20 | Step 1: configure OAuth provider | POST /setup/providers saves provider to oauth_providers table |
|
||||
| AUTH-T21 | Step 1: requires at least one provider | POST /setup/providers with empty config returns 422 |
|
||||
| AUTH-T22a | Step 2: login via configured provider | After provider setup, /auth/login shows newly configured provider |
|
||||
| AUTH-T22b | Step 3: first user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T22c | Setup completed flag set | After community creation, system_settings.setup_completed = true |
|
||||
| AUTH-T22d | Subsequent users see normal login | With setup_completed=true, normal login page shown |
|
||||
| AUTH-T22e | Setup routes blocked after completion | /setup/providers returns 403 when setup_completed=true |
|
||||
|
||||
### 8.5 Invite Flow
|
||||
|
||||
@@ -222,7 +251,7 @@ Attributes:
|
||||
| AUTH-T24 | Exhausted invite | Shows error page |
|
||||
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
|
||||
|
||||
### 8.6 Reverse Proxy
|
||||
### 8.7 Reverse Proxy
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -234,7 +263,7 @@ Attributes:
|
||||
| AUTH-T31 | Headers injected | X-User-Id, X-Trace-Id present on proxied request |
|
||||
| AUTH-T32 | Original auth headers stripped | Client cannot forge X-User-Id |
|
||||
|
||||
### 8.7 Rate Limiting
|
||||
### 8.8 Rate Limiting
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -243,14 +272,14 @@ Attributes:
|
||||
| AUTH-T35 | Rate limit per-user | Different users have independent limits |
|
||||
| AUTH-T36 | Rate limit per-IP for auth | OAuth callback rate limited by IP |
|
||||
|
||||
### 8.8 Logout
|
||||
### 8.9 Logout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T37 | Logout destroys session | POST /auth/logout deletes session from DB, clears cookie |
|
||||
| AUTH-T38 | Logout with invalid session | Returns 200 (idempotent), clears cookie |
|
||||
|
||||
### 8.9 Health Check
|
||||
### 8.10 Health Check
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -259,7 +288,113 @@ Attributes:
|
||||
|
||||
---
|
||||
|
||||
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
|
||||
## 9. Service Configuration
|
||||
|
||||
### 9.1 Config Shape
|
||||
|
||||
```clojure
|
||||
{:server {:host "0.0.0.0" :port 3000}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "..." :pool-size 5}
|
||||
:oauth {:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled false}
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}
|
||||
;; ↑ Fallback seed only — auto-migrated to oauth_providers DB table on first startup.
|
||||
;; Ignored once providers exist in DB. Manage providers via admin API after setup.
|
||||
:services {:api {:host "localhost" :port 3001}
|
||||
:web-sm {:host "localhost" :port 3002}
|
||||
:tui-sm {:host "localhost" :port 3003}}
|
||||
:session {:ttl-days 30
|
||||
:cookie-name "ajet_session"
|
||||
:cookie-secure true} ;; false in dev
|
||||
:rate-limit {:enabled true}
|
||||
:cors {:allowed-origins ["https://chat.example.com"]
|
||||
:allowed-methods [:get :post :put :delete :options]
|
||||
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
|
||||
:max-age 86400}}
|
||||
```
|
||||
|
||||
### 9.2 CORS Configuration
|
||||
|
||||
CORS headers applied to all responses:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: <configured origin or request Origin if in allowed list>
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization, X-Trace-Id
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
- **Dev mode:** Allow `http://localhost:*` origins
|
||||
- **Prod mode:** Strict origin whitelist from config
|
||||
- **Preflight requests:** `OPTIONS` handled and returned immediately (no proxy)
|
||||
|
||||
### 9.3 Audit Logging
|
||||
|
||||
**What's logged:**
|
||||
- All admin actions: kick, ban, mute, role change, channel delete, webhook create/delete, invite create/revoke
|
||||
- Authentication events: login success, login failure, logout, session expiry
|
||||
- Rate limit violations
|
||||
|
||||
**Audit log table (in PostgreSQL, written by Auth GW):**
|
||||
```sql
|
||||
audit_log (
|
||||
id uuid PK,
|
||||
actor_id uuid 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) |
|
||||
|
||||
+100
-9
@@ -310,9 +310,100 @@ Last-Event-ID: <id> (for reconnection)
|
||||
- Unread counts in sidebar (same as web)
|
||||
- Notification list accessible via slash command `/notifications`
|
||||
|
||||
## 5. Test Cases
|
||||
## 5. Distribution & Packaging
|
||||
|
||||
### 5.1 CLI Authentication
|
||||
### 5.1 bbin Packaging
|
||||
|
||||
**Build:** Compile to an uberjar, then distribute via bbin (Babashka binary installer).
|
||||
|
||||
```bash
|
||||
# Build uberjar
|
||||
clj -T:build uber # produces target/ajet-chat-cli.jar
|
||||
|
||||
# Install locally via bbin
|
||||
bbin install . --as ajet # installs 'ajet' command from local project
|
||||
|
||||
# Install from remote (for users)
|
||||
bbin install io.github.ajet/ajet-chat-cli --as ajet
|
||||
```
|
||||
|
||||
**Binary:** The `ajet` command is a shell wrapper that invokes `java -jar` (or `bb` if Babashka-compatible). First run may be slow due to JVM startup; subsequent runs benefit from Drip or CDS caching.
|
||||
|
||||
### 5.2 Babashka Compatibility
|
||||
|
||||
**Goal:** CLI mode should be Babashka-compatible for fast startup. TUI mode requires JVM (clojure-tui dependency).
|
||||
|
||||
**Constraints for Babashka compatibility:**
|
||||
- No `deftype` / `defrecord` (use maps + protocols sparingly)
|
||||
- No `gen-class`
|
||||
- Use `babashka.http-client` (not `clj-http`)
|
||||
- Use `clojure.data.json` (bb-compatible)
|
||||
- Avoid Java interop beyond what bb supports
|
||||
- All CLI commands (non-TUI) target < 100ms startup via bb
|
||||
|
||||
**Fallback:** If Babashka compatibility proves too restrictive, ship as JVM uberjar with CDS (Class Data Sharing) for faster startup.
|
||||
|
||||
### 5.3 Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | General error (API error, unexpected failure) |
|
||||
| 2 | Usage error (bad arguments, unknown command) |
|
||||
| 3 | Authentication error (not logged in, token expired) |
|
||||
| 4 | Permission error (403 from API) |
|
||||
| 5 | Not found (404 from API — channel, message, user doesn't exist) |
|
||||
| 130 | Interrupted (Ctrl+C / SIGINT) |
|
||||
|
||||
### 5.4 Error Message UX
|
||||
|
||||
All errors follow this format:
|
||||
```
|
||||
Error: <short description>
|
||||
|
||||
<details or suggestion>
|
||||
|
||||
Hint: <actionable next step>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
Error: Not logged in
|
||||
|
||||
No session token found. You need to authenticate first.
|
||||
|
||||
Hint: Run 'ajet login' to sign in
|
||||
```
|
||||
|
||||
```
|
||||
Error: Channel not found: #nonexistent
|
||||
|
||||
The channel doesn't exist or you don't have access.
|
||||
|
||||
Hint: Run 'ajet channels' to see available channels
|
||||
```
|
||||
|
||||
```
|
||||
Error: Edit window expired
|
||||
|
||||
Messages can only be edited within 1 hour of creation.
|
||||
This message was sent 3 hours ago.
|
||||
```
|
||||
|
||||
### 5.5 Offline Behavior
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Server unreachable | `Error: Cannot connect to server at chat.example.com` + hint to check config |
|
||||
| Timeout (> 10s) | `Error: Request timed out` + hint to retry |
|
||||
| TUI SSE disconnects | Status bar shows "Reconnecting..." + auto-retry with backoff |
|
||||
| TUI SSE reconnects | Catches up on missed events, no user action needed |
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Cases
|
||||
|
||||
### 6.1 CLI Authentication
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -323,7 +414,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| CLI-T5 | Expired token | Commands return clear "session expired, run ajet login" message |
|
||||
| CLI-T6 | No config exists | First run creates config dir and prompts for server URL |
|
||||
|
||||
### 5.2 CLI Commands
|
||||
### 6.2 CLI Commands
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -351,7 +442,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| CLI-T28 | Unknown command | Prints help with suggestion |
|
||||
| CLI-T29 | No arguments | Prints usage/help |
|
||||
|
||||
### 5.3 TUI Launch & Layout
|
||||
### 6.3 TUI Launch & Layout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -362,7 +453,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T5 | Online indicators | Online users have green dot in DM list |
|
||||
| TUI-T6 | Status bar | Shows keybindings and connection status |
|
||||
|
||||
### 5.4 TUI Navigation
|
||||
### 6.4 TUI Navigation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -377,7 +468,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T15 | Esc closes panels | Thread panel or search closes on Esc |
|
||||
| TUI-T16 | Community switch | Click community in sidebar → channels update |
|
||||
|
||||
### 5.5 TUI Messaging
|
||||
### 6.5 TUI Messaging
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -393,7 +484,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T26 | Reply in thread | Type in thread input → reply sent |
|
||||
| TUI-T27 | Image paste | Not supported in TUI (CLI `--image` flag instead) |
|
||||
|
||||
### 5.6 TUI Real-Time
|
||||
### 6.6 TUI Real-Time
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -404,7 +495,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T32 | SSE reconnect | Connection lost → "Reconnecting..." → auto-reconnects |
|
||||
| TUI-T33 | Bell notification | Terminal bell on @mention or DM |
|
||||
|
||||
### 5.7 TUI Rendering
|
||||
### 6.7 TUI Rendering
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -422,7 +513,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T45 | Long message wrapping | Long messages wrap correctly within pane width |
|
||||
| TUI-T46 | Terminal resize | Layout reflows on terminal resize event |
|
||||
|
||||
### 5.8 TUI Error Handling
|
||||
### 6.8 TUI Error Handling
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
|
||||
@@ -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=<strong-random-password>
|
||||
GITHUB_CLIENT_ID=<from-github-oauth-app> # seed only — migrated to DB on first start
|
||||
GITHUB_CLIENT_SECRET=<from-github-oauth-app> # seed only — migrated to DB on first start
|
||||
MINIO_ACCESS_KEY=<random-access-key>
|
||||
MINIO_SECRET_KEY=<random-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 |
|
||||
@@ -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 `@<user:>` (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 |
|
||||
|
||||
+71
-5
@@ -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 |
|
||||
|----|------|-------------|
|
||||
|
||||
+142
-8
@@ -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 |
|
||||
|----|------|-------------|
|
||||
|
||||
Reference in New Issue
Block a user