init prds

This commit is contained in:
2026-02-17 01:08:02 -05:00
parent 79b6a5e225
commit a3b28549b4
8 changed files with 2329 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# ajet-chat PRDs
Product Requirements Documents for ajet-chat v1.
## Documents
| 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 |
| [mobile](mobile.md) | Deferred — PWA recommended for v1 | 0 | v2+ |
**Total test cases: ~332**
## Key Product Decisions
- **Multi-community** — users can be in multiple communities (Discord model)
- **Discord-style layout** — community icon strip, categories, channel sidebar
- **DMs are global** — separate from communities, always accessible
- **Flat threads** — Slack-style 1-level threads, no nesting
- **Discord-flavor markdown** — bold, italic, strikethrough, underline, spoilers, code blocks
- **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)
- **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)
- **Full slash commands** — /help, /kick, /ban, /mute, /topic, /nick, /token, /webhook, /status
- **Global search** — messages + channels + users via PostgreSQL tsvector
- **Full Datastar hypermedia** — server-rendered HTML, SSE fragment updates, minimal client JS
- **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
+611
View File
@@ -0,0 +1,611 @@
# PRD: API Service
**Module:** `api/` | **Namespace:** `ajet.chat.api.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The API service is the stateless REST backend — the only service (besides Auth GW) with direct PostgreSQL access. It handles all data reads/writes and publishes events to NATS after mutations. All other services interact with data through the API.
## 2. Architecture
```
Auth Gateway → API Service → PostgreSQL
→ NATS (publish events)
→ MinIO (file storage)
```
- **No direct client access** — all requests arrive via Auth GW with validated session context
- **Request headers from Auth GW:**
- `X-User-Id: <uuid>` — authenticated user
- `X-User-Role: <owner|admin|member>` — role in the community context
- `X-Community-Id: <uuid>` — current community (for community-scoped endpoints)
- `X-Trace-Id: <uuid>` — request tracing
## 3. Database Schema
### 3.1 Migrations (Migratus, sequential)
```
001-create-users.up.sql
002-create-oauth-accounts.up.sql
003-create-communities.up.sql
004-create-community-members.up.sql
005-create-channel-categories.up.sql
006-create-channels.up.sql
007-create-channel-members.up.sql
008-create-messages.up.sql
009-create-attachments.up.sql
010-create-reactions.up.sql
011-create-mentions.up.sql
012-create-notifications.up.sql
013-create-sessions.up.sql
014-create-api-users.up.sql
015-create-api-tokens.up.sql
016-create-webhooks.up.sql
017-create-invites.up.sql
018-add-search-indexes.up.sql
```
### 3.2 Tables
```sql
-- Core entities
users (id uuid PK, username text UNIQUE, display_name text, email text, avatar_url text, status_text text, created_at timestamptz)
oauth_accounts (id uuid PK, user_id uuid FKusers, provider text, provider_user_id text, created_at timestamptz, UNIQUE(provider, provider_user_id))
communities (id uuid PK, name text, slug text UNIQUE, created_at timestamptz)
community_members (community_id uuid FK, user_id uuid FK, role text CHECK(owner/admin/member), nickname text, avatar_url text, joined_at timestamptz, PK(community_id, user_id))
channel_categories (id uuid PK, community_id uuid FK, name text, position int)
channels (id uuid PK, community_id uuid FK NULL, category_id uuid FK NULL, name text, type text CHECK(text/dm/group_dm), visibility text CHECK(public/private), topic text, created_at timestamptz)
channel_members (channel_id uuid FK, user_id uuid FK, joined_at timestamptz, last_read_message_id uuid, PK(channel_id, user_id))
messages (id uuid PK, channel_id uuid FK, user_id uuid FK, parent_id uuid FKmessages NULL, body_md text, created_at timestamptz, edited_at timestamptz NULL)
attachments (id uuid PK, message_id uuid FK, filename text, content_type text, size_bytes bigint, storage_key text)
reactions (message_id uuid FK, user_id uuid FK, emoji text, created_at timestamptz, PK(message_id, user_id, emoji))
mentions (id uuid PK, message_id uuid FK, target_type text CHECK(user/channel/here), target_id uuid NULL)
notifications (id uuid PK, user_id uuid FK, type text CHECK(mention/dm/thread_reply/invite/system), source_id uuid, read boolean DEFAULT false, created_at timestamptz)
webhooks (id uuid PK, community_id uuid FK, channel_id uuid FK, name text, avatar_url text, token_hash text, created_by uuid FK, created_at timestamptz)
invites (id uuid PK, community_id uuid FK, created_by uuid FK, code text UNIQUE, max_uses int NULL, uses int DEFAULT 0, expires_at timestamptz NULL, created_at timestamptz)
-- Indexes
idx_messages_channel_created ON messages(channel_id, created_at)
idx_messages_parent ON messages(parent_id) WHERE parent_id IS NOT NULL
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)
```
## 4. API Endpoints
### 4.1 Communities
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities` | Create community (first-user bootstrap or new) | User |
| GET | `/api/communities` | List user's communities | User |
| GET | `/api/communities/:id` | Get community details | Member |
| PUT | `/api/communities/:id` | Update community name/slug | Owner |
| DELETE | `/api/communities/:id` | Delete community | Owner |
**POST /api/communities**
```
Request: {"name": "My Team", "slug": "my-team"}
Response: {"id": "uuid", "name": "My Team", "slug": "my-team", "created_at": "..."}
Side effects:
- Creates community
- Adds requesting user as owner
- Creates #general channel (text, public)
- Publishes :community/created to chat.events.{id}
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| COM-T1 | Create community | POST creates community, user is owner, #general exists |
| COM-T2 | Slug uniqueness | POST with existing slug returns 409 |
| COM-T3 | Slug format validation | Slug with spaces/uppercase returns 422 |
| COM-T4 | List communities | GET returns only communities user is a member of |
| COM-T5 | Get community (member) | GET returns community details |
| COM-T6 | Get community (non-member) | GET returns 403 |
| COM-T7 | Update community (owner) | PUT updates name/slug |
| COM-T8 | Update community (admin) | PUT returns 403 (owner-only) |
| COM-T9 | Delete community (owner) | DELETE removes community and all associated data |
| COM-T10 | Delete community (non-owner) | DELETE returns 403 |
---
### 4.2 Channels
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/communities/:cid/channels` | List channels in community | Member |
| POST | `/api/communities/:cid/channels` | Create channel | Admin+ |
| GET | `/api/channels/:id` | Get channel details | Channel member (or community member if public) |
| PUT | `/api/channels/:id` | Update channel | Admin+ |
| DELETE | `/api/channels/:id` | Delete channel | Admin+ |
| POST | `/api/channels/:id/join` | Join channel (public only) | Member |
| POST | `/api/channels/:id/leave` | Leave channel | Member |
| GET | `/api/channels/:id/members` | List channel members | Channel member |
**POST /api/communities/:cid/channels**
```
Request: {"name": "backend", "type": "text", "visibility": "public", "category_id": "uuid or null"}
Response: {"id": "uuid", "name": "backend", ...}
Side effects:
- Creates channel
- Adds creator as channel member
- Publishes :channel/created to chat.events.{community-id}
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| CH-T1 | Create public channel | POST creates channel, creator is member, event published |
| CH-T2 | Create private channel | POST with visibility=private works |
| CH-T3 | Create channel (member role) | POST returns 403 for non-admin |
| CH-T4 | Duplicate channel name | POST with existing name in same community returns 409 |
| CH-T5 | List channels | GET returns public channels + private channels user is in |
| CH-T6 | Join public channel | POST /join adds user to channel, publishes :member/joined |
| CH-T7 | Join private channel | POST /join returns 403 (must be invited) |
| CH-T8 | Leave channel | POST /leave removes membership, publishes :member/left |
| CH-T9 | Leave last channel | Leaving #general is allowed |
| CH-T10 | Delete channel | DELETE removes channel, all messages, publishes :channel/deleted |
| CH-T11 | Update channel topic | PUT with new topic, publishes :channel/updated |
| CH-T12 | Assign category | PUT with category_id moves channel to category |
---
### 4.3 Channel Categories
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/communities/:cid/categories` | List categories (ordered) | Member |
| POST | `/api/communities/:cid/categories` | Create category | Admin+ |
| PUT | `/api/categories/:id` | Update category (name, position) | Admin+ |
| DELETE | `/api/categories/:id` | Delete category (channels become uncategorized) | Admin+ |
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| CAT-T1 | Create category | POST creates with position, returns category |
| CAT-T2 | Reorder categories | PUT with new position reorders correctly |
| CAT-T3 | Delete category | Channels in deleted category become uncategorized (category_id = null) |
| CAT-T4 | List categories with channels | GET returns categories with nested channel list |
---
### 4.4 Messages
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/channels/:id/messages` | List messages (paginated) | Channel member |
| POST | `/api/channels/:id/messages` | Send message | Channel member |
| GET | `/api/messages/:id` | Get single message | Channel member |
| PUT | `/api/messages/:id` | Edit message (1-hour window) | Author |
| DELETE | `/api/messages/:id` | Delete message | Author or Admin+ |
| GET | `/api/messages/:id/thread` | Get thread replies | Channel member |
**Pagination:** Cursor-based using message UUID.
```
GET /api/channels/:id/messages?before=<uuid>&limit=50
GET /api/channels/:id/messages?after=<uuid>&limit=50
Default limit: 50, max: 100
```
**POST /api/channels/:id/messages**
```
Request: {"body_md": "hello @<user:uuid> check #<channel:uuid>", "parent_id": null}
Response: {"id": "uuid", "channel_id": "...", "user_id": "...", "body_md": "...", "created_at": "..."}
Side effects:
- Creates message row
- Parses mentions → creates mention rows
- Creates notification rows for mentioned users
- Publishes :message/created to appropriate NATS subject
- If thread reply: notifies thread participants
```
**PUT /api/messages/:id (edit)**
```
Request: {"body_md": "updated text"}
Response: {"id": "uuid", ..., "edited_at": "..."}
Validation:
- User must be the author
- created_at must be within 1 hour of now
- Re-parses mentions, updates mention rows
- Publishes :message/edited
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| MSG-T1 | Send message | POST creates message, returns with ID and timestamps |
| MSG-T2 | Send message to channel user is not in | POST returns 403 |
| MSG-T3 | Send empty message | POST with empty body returns 422 |
| MSG-T4 | Send message with mentions | Mention rows created, notifications created for mentioned users |
| MSG-T5 | Send message with @here | Notifications created for all online channel members |
| MSG-T6 | Send thread reply | POST with parent_id creates threaded message, notifies thread participants |
| MSG-T7 | Thread does not nest | POST with parent_id pointing to another reply is rejected (or parent_id resolves to root) |
| MSG-T8 | Get messages (default pagination) | GET returns last 50 messages, newest last |
| MSG-T9 | Get messages (before cursor) | GET with `?before=uuid` returns older messages |
| MSG-T10 | Get messages (after cursor) | GET with `?after=uuid` returns newer messages |
| MSG-T11 | Edit message within window | PUT succeeds within 1 hour, sets edited_at |
| MSG-T12 | Edit message after window | PUT returns 403 after 1 hour |
| MSG-T13 | Edit message (non-author) | PUT returns 403 |
| MSG-T14 | Edit updates mentions | Editing to add @user creates new mention + notification |
| MSG-T15 | Delete own message | DELETE removes message, publishes :message/deleted |
| MSG-T16 | Delete other's message (admin) | DELETE succeeds for admin/owner |
| MSG-T17 | Delete other's message (member) | DELETE returns 403 |
| MSG-T18 | Get thread | GET /thread returns all replies to a message, ordered by created_at |
| MSG-T19 | NATS event published | After send, :message/created event appears on correct subject |
| MSG-T20 | DM message event | Message in DM channel publishes to `chat.dm.{channel-id}` |
| MSG-T21 | Community message event | Message in community channel publishes to `chat.events.{community-id}` |
---
### 4.5 Reactions
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| PUT | `/api/messages/:id/reactions/:emoji` | Add reaction (idempotent) | Channel member |
| DELETE | `/api/messages/:id/reactions/:emoji` | Remove reaction | Reactor |
| GET | `/api/messages/:id/reactions` | List reactions on message | Channel member |
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| RXN-T1 | Add reaction | PUT creates reaction, publishes :reaction/added |
| RXN-T2 | Add duplicate reaction | PUT is idempotent, no error |
| RXN-T3 | Remove reaction | DELETE removes, publishes :reaction/removed |
| RXN-T4 | Remove non-existent reaction | DELETE returns 404 |
| RXN-T5 | List reactions | GET returns reactions grouped by emoji with user lists |
| RXN-T6 | Multiple users same emoji | Both reactions stored, count shows 2 |
---
### 4.6 DMs (Global)
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/dms` | List user's DM channels | User |
| POST | `/api/dms` | Create or get existing DM | User |
| POST | `/api/dms/group` | Create group DM | User |
**POST /api/dms**
```
Request: {"user_id": "uuid"}
Response: {"id": "channel-uuid", "type": "dm", "members": [...]}
Behavior: Returns existing DM if one exists between the two users, otherwise creates new.
```
**POST /api/dms/group**
```
Request: {"user_ids": ["uuid1", "uuid2", ...]}
Response: {"id": "channel-uuid", "type": "group_dm", "members": [...]}
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| DM-T1 | Create DM | POST creates DM channel with both users as members |
| DM-T2 | Get existing DM | POST for existing pair returns same channel |
| DM-T3 | Create group DM | POST /group creates group_dm with all specified users |
| DM-T4 | List DMs | GET returns all DM/group_dm channels for user, ordered by last message |
| DM-T5 | DM with self | POST with own user_id returns 422 |
| DM-T6 | Group DM minimum | POST /group with < 2 other users returns 422 |
---
### 4.7 Users & Profiles
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/me` | Get current user | User |
| PUT | `/api/me` | Update profile (display_name, status_text) | User |
| GET | `/api/users/:id` | Get user profile | User |
| GET | `/api/communities/:cid/members` | List community members | Member |
| PUT | `/api/communities/:cid/members/:uid` | Update member (nickname, role) | Admin+ (role: Owner only) |
| DELETE | `/api/communities/:cid/members/:uid` | Remove/kick member | Admin+ |
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| USR-T1 | Get current user | GET /me returns authenticated user's profile |
| USR-T2 | Update display name | PUT /me with display_name updates it |
| USR-T3 | Update status text | PUT /me with status_text updates it |
| USR-T4 | Get user profile | GET /users/:id returns public profile |
| USR-T5 | List community members | GET returns all members with roles and nicknames |
| USR-T6 | Set community nickname | PUT /members/:uid with nickname (admin) |
| USR-T7 | Change member role (owner) | Owner can promote member to admin |
| USR-T8 | Change member role (admin) | Admin cannot change roles (403) |
| USR-T9 | Kick member (admin) | DELETE removes member, publishes :member/kicked |
| USR-T10 | Kick owner | Cannot kick owner (403) |
---
### 4.8 Notifications
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/notifications` | List notifications (paginated) | User |
| POST | `/api/notifications/read` | Mark notifications as read | User |
| GET | `/api/notifications/unread-count` | Get unread count | User |
**Pagination:** `?after=<uuid>&limit=50&unread=true`
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| NTF-T1 | Get notifications | GET returns notifications newest-first |
| NTF-T2 | Filter unread | GET with ?unread=true returns only unread |
| NTF-T3 | Mark read | POST with notification IDs marks them read |
| NTF-T4 | Unread count | GET /unread-count returns integer count |
| NTF-T5 | Notification on mention | @mention in message creates notification |
| NTF-T6 | Notification on DM | New DM message creates notification |
| NTF-T7 | Notification on thread reply | Reply to your thread root creates notification |
---
### 4.9 Presence & Typing
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/heartbeat` | Report online status | User |
| GET | `/api/communities/:cid/presence` | Get online members | Member |
**POST /api/heartbeat**
```
Response: 200 OK (immediate)
Side effects:
- Async: update last_seen_at in DB
- Async: publish :presence/online to chat.presence.{community-id} for each community
```
**Presence logic:**
- Online: last heartbeat < 2 minutes ago
- Offline: last heartbeat >= 2 minutes ago
- Clients send heartbeat every 60 seconds
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| PRS-T1 | Heartbeat returns 200 immediately | POST /heartbeat responds fast (< 50ms) |
| PRS-T2 | Heartbeat updates last_seen | DB shows updated last_seen_at after heartbeat |
| PRS-T3 | Online presence | User with recent heartbeat appears in presence list |
| PRS-T4 | Offline after timeout | User without heartbeat for 2+ minutes not in online list |
| PRS-T5 | Presence event published | Heartbeat publishes to NATS presence subject |
---
### 4.10 Search
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/search` | Global search | User |
**Parameters:**
```
?q=search+term — required, search query
&type=messages|channels|users — optional filter (default: all)
&community_id=uuid — optional, scope to community
&channel_id=uuid — optional, scope to channel
&from=uuid — optional, filter by author
&after=datetime — optional, date range start
&before=datetime — optional, date range end
&cursor=uuid&limit=20 — pagination
```
**Behavior:**
- Messages: PostgreSQL `tsvector` full-text search, only in channels user has access to
- Channels: name substring match within user's communities
- Users: username/display_name substring match
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| SRC-T1 | Search messages by keyword | Returns matching messages user can access |
| SRC-T2 | Search respects access | Messages in channels user isn't in are excluded |
| SRC-T3 | Search in specific channel | `channel_id` filter narrows results |
| SRC-T4 | Search by author | `from` filter returns only that user's messages |
| SRC-T5 | Search channels | `type=channels` returns matching channel names |
| SRC-T6 | Search users | `type=users` returns matching usernames/display names |
| SRC-T7 | Empty query | Returns 422 |
| SRC-T8 | Pagination | Cursor-based pagination returns next page |
| SRC-T9 | Date range filter | `after`/`before` narrows message results |
---
### 4.11 Invites
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities/:cid/invites` | Create invite link | Admin+ |
| GET | `/api/communities/:cid/invites` | List active invites | Admin+ |
| DELETE | `/api/invites/:id` | Revoke invite | Admin+ |
| POST | `/api/invites/:code/accept` | Accept invite (join community) | User |
| POST | `/api/communities/:cid/invites/direct` | Direct invite (by user ID) | Admin+ |
**POST /api/communities/:cid/invites**
```
Request: {"max_uses": 10, "expires_in_hours": 48} — both optional
Response: {"id": "uuid", "code": "abc123", "url": "https://chat.example.com/invite/abc123", ...}
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| INV-T1 | Create invite link | POST creates invite with unique code |
| INV-T2 | Accept invite | POST /accept adds user to community as member |
| INV-T3 | Accept expired invite | POST /accept returns 410 (gone) |
| INV-T4 | Accept max-uses exhausted | POST /accept returns 410 after max uses reached |
| INV-T5 | Accept invite (already member) | POST /accept returns 200 (idempotent) |
| INV-T6 | List invites | GET returns active (non-expired, non-exhausted) invites |
| INV-T7 | Revoke invite | DELETE invalidates the invite |
| INV-T8 | Direct invite | POST /direct creates invite + notification for target user |
| INV-T9 | Create invite (member role) | POST returns 403 |
---
### 4.12 Webhooks (Incoming)
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/communities/:cid/webhooks` | Create webhook | Admin+ |
| GET | `/api/communities/:cid/webhooks` | List webhooks | Admin+ |
| DELETE | `/api/webhooks/:id` | Delete webhook | Admin+ |
| POST | `/api/webhooks/:id/incoming` | Post via webhook | Webhook token |
**POST /api/webhooks/:id/incoming**
```
Headers: Authorization: Bearer <webhook-token>
Request: {"content": "Build #42 passed!", "username": "CI Bot", "avatar_url": "..."}
Response: 204 No Content
Side effects: Creates message in webhook's channel, publishes event
```
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| WH-T1 | Create webhook | POST creates webhook with token, returns token once |
| WH-T2 | Post via webhook | POST /incoming creates message in channel |
| WH-T3 | Post with invalid token | Returns 401 |
| WH-T4 | Post with custom username | Message shows webhook's custom username |
| WH-T5 | Delete webhook | DELETE removes webhook, token becomes invalid |
| WH-T6 | List webhooks | GET returns webhooks (without tokens) |
---
### 4.13 Slash Commands
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/commands` | Execute slash command | User |
**Request:**
```json
{"command": "/kick @<user:uuid>", "channel_id": "uuid", "community_id": "uuid"}
```
**Commands v1:**
| Command | Args | Permission | Description |
|---------|------|------------|-------------|
| `/help` | `[command]` | All | Show help or specific command help |
| `/topic` | `<text>` | Admin+ | Set channel topic |
| `/nick` | `<nickname>` | All | Set community nickname |
| `/invite` | `[max_uses] [expires_hours]` | Admin+ | Generate invite link |
| `/kick` | `@<user>` | Admin+ | Kick user from community |
| `/ban` | `@<user>` | Admin+ | Ban user from community |
| `/mute` | `@<user> <duration>` | Admin+ | Mute user for duration |
| `/token create` | `<name> [scopes]` | Owner | Create API token |
| `/token revoke` | `<name>` | Owner | Revoke API token |
| `/token list` | | Owner | List API tokens |
| `/webhook create` | `<name> [channel]` | Admin+ | Create incoming webhook |
| `/webhook delete` | `<name>` | Admin+ | Delete webhook |
| `/webhook list` | | Admin+ | List webhooks |
| `/status` | `<text>` | All | Set status text |
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| CMD-T1 | /help returns command list | All commands listed with descriptions |
| CMD-T2 | /topic sets channel topic | Channel topic updated, event published |
| CMD-T3 | /topic (non-admin) | Returns 403 |
| CMD-T4 | /nick sets nickname | Community member nickname updated |
| CMD-T5 | /kick removes user | User removed from community, event published |
| CMD-T6 | /kick (non-admin) | Returns 403 |
| CMD-T7 | /ban bans user | User banned (cannot rejoin) |
| CMD-T8 | /mute mutes user | User cannot send messages for duration |
| CMD-T9 | /token create | Creates API token, returns token string once |
| CMD-T10 | /webhook create | Creates webhook, returns webhook URL + token |
| CMD-T11 | Unknown command | Returns error message suggesting /help |
| CMD-T12 | /status sets status text | User's status_text updated |
---
### 4.14 File Upload
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/channels/:id/upload` | Upload image | Channel member |
**Request:** Multipart form data with image file.
**Constraints:** Images only (JPEG, PNG, GIF, WebP). Max size: 10MB.
```
Response: {"id": "uuid", "filename": "screenshot.png", "content_type": "image/png", "size_bytes": 12345, "url": "/files/uuid/screenshot.png"}
```
**Flow:**
1. Validate file type and size
2. Upload to MinIO with storage key `attachments/{uuid}/{filename}`
3. Return attachment metadata (to be referenced in message)
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| UPL-T1 | Upload JPEG | File stored in MinIO, attachment record created |
| UPL-T2 | Upload PNG | Same as above |
| UPL-T3 | Upload non-image | Returns 422 (only images allowed) |
| UPL-T4 | Upload too large | Returns 413 (> 10MB) |
| UPL-T5 | Upload to channel user isn't in | Returns 403 |
---
### 4.15 Read Tracking
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/api/channels/:id/read` | Mark channel as read | Channel member |
**Request:**
```json
{"last_read_message_id": "uuid"}
```
Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread counts.
**Test Cases:**
| ID | Test | Description |
|----|------|-------------|
| RD-T1 | Mark read | POST updates last_read_message_id |
| RD-T2 | Unread count derivation | Messages after last_read_message_id are "unread" |
| RD-T3 | Mark read (non-member) | Returns 403 |
---
## 5. Cross-Cutting Concerns
### 5.1 Error Response Format
```json
{"error": {"code": "NOT_FOUND", "message": "Channel not found", "details": {}}}
```
### 5.2 Rate Limiting
- Applied at Auth GW level, not in API service itself
- API trusts that Auth GW has already rate-limited
### 5.3 NATS Publishing
- Every mutation endpoint publishes an event to the appropriate NATS subject
- Events are fire-and-forget (API does not wait for subscriber acknowledgment)
- Event publishing failure should be logged but not fail the HTTP response
### 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
+287
View File
@@ -0,0 +1,287 @@
# PRD: Auth Gateway
**Module:** `auth-gw/` | **Namespace:** `ajet.chat.auth-gw.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The Auth Gateway is the single edge entry point for all client traffic. It terminates sessions, validates tokens, and reverse-proxies authenticated requests to internal services (API, Web SM, TUI SM). It also handles OAuth login flows and session management.
## 2. Architecture
```
Client → (nginx TLS, prod) → Auth Gateway → API Service
→ Web Session Manager
→ TUI Session Manager
```
**Auth GW has direct PG access** for session/token table lookups — this avoids a round-trip to the API for every request.
## 3. Route Table
| Path Pattern | Target | Auth Required | Description |
|--------------|--------|---------------|-------------|
| `GET /` | Web SM | Session | Web app root |
| `GET /app/*` | Web SM | Session | Web app pages |
| `GET /sse/*` | Web SM | Session | SSE streams for web |
| `POST /web/*` | Web SM | Session | Web form submissions / Datastar signals |
| `GET,POST /api/*` | API | Session or API Token | REST API |
| `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/callback/:provider` | Self | None | OAuth callback |
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
| `GET /health` | Self | None | Health check |
## 4. Authentication Flows
### 4.1 Session Token Validation
Every authenticated request follows this flow:
```
1. Extract token from Cookie: ajet_session=<base64url-token>
2. bcrypt-verify token against sessions.token_hash
3. Check sessions.expires_at > now
4. If valid:
a. Extend session TTL (rolling expiry) — async, don't block request
b. Inject headers: X-User-Id, X-User-Role, X-Community-Id, X-Trace-Id
c. Proxy to target service
5. If invalid/expired: redirect to /auth/login (web) or 401 (API/TUI)
```
**Rolling expiry:** Each valid request extends `expires_at` by the session TTL (default: 30 days). This is done asynchronously to avoid adding latency.
**Token format:** 32 random bytes, base64url-encoded (43 characters). Stored as bcrypt hash.
### 4.2 API Token Validation
For `Authorization: Bearer <token>` requests to `/api/*`:
```
1. Extract token from Authorization header
2. bcrypt-verify against api_tokens.token_hash
3. Check api_tokens.expires_at > now (if set)
4. Check scopes allow the requested operation
5. If valid: inject X-User-Id (api_user's owner), X-Trace-Id
6. If invalid: 401
```
### 4.3 OAuth Login Flow
**Supported providers:** GitHub, Gitea, Generic OIDC
```
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):
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)
```
**OAuth config shape:**
```clojure
{:oauth
{:github {:client-id "..." :client-secret "..." :enabled true}
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled true}
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
```
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
### 4.4 First-User Bootstrap
```
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
```
### 4.5 Invite Flow
```
1. User visits /invite/:code
2. Auth GW checks invite validity (exists, not expired, not exhausted)
- Invalid: show error page
- Valid: store invite code in cookie/session, redirect to /auth/login
3. After OAuth login, if pending invite code:
a. Accept invite (join community)
b. Redirect to community
```
## 5. Reverse Proxy Behavior
**Request forwarding:**
- Strip auth headers from original request
- Inject: `X-User-Id`, `X-User-Role`, `X-Community-Id`, `X-Trace-Id`, `X-Forwarded-For`
- Forward request body, method, path, query string unchanged
- SSE: hold connection open, stream response bytes through
**Response forwarding:**
- Pass through status code, headers, body unchanged
- For SSE responses: stream chunks as they arrive (no buffering)
**Service discovery (v1):** Static config — all services on localhost with configured ports.
```clojure
{:services
{:api {:host "localhost" :port 3001}
:web-sm {:host "localhost" :port 3002}
:tui-sm {:host "localhost" :port 3003}}}
```
## 6. Rate Limiting
| Endpoint Pattern | Limit | Window |
|-----------------|-------|--------|
| `POST /auth/callback/*` | 10 | 1 min per IP |
| `POST /api/*` | 60 | 1 min per user |
| `GET /api/*` | 120 | 1 min per user |
| `POST /api/webhooks/*/incoming` | 30 | 1 min per webhook |
| `GET /sse/*`, `GET /tui/sse/*` | 5 | 1 min per user (connection attempts) |
**Implementation:** In-memory token bucket (atom-based). No Redis needed for v1 (single instance).
## 7. Session Cookie
```
Name: ajet_session
Value: <base64url-encoded-token>
Attributes:
HttpOnly: true
Secure: true (prod only)
SameSite: Lax
Path: /
Max-Age: 2592000 (30 days)
```
## 8. Test Cases
### 8.1 Session Validation
| ID | Test | Description |
|----|------|-------------|
| AUTH-T1 | Valid session cookie | Request proxied with injected headers |
| AUTH-T2 | Expired session | Returns 401 (API) or redirect to login (web) |
| AUTH-T3 | Invalid/tampered token | Returns 401 |
| AUTH-T4 | Missing cookie | Returns 401 (API) or redirect to login (web) |
| AUTH-T5 | Session TTL extension | Valid request extends expires_at |
| AUTH-T6 | Concurrent requests | Multiple requests with same session all succeed |
### 8.2 API Token Validation
| ID | Test | Description |
|----|------|-------------|
| AUTH-T7 | Valid API token | Request proxied with X-User-Id |
| AUTH-T8 | Expired API token | Returns 401 |
| AUTH-T9 | Invalid scope | Returns 403 (scope mismatch) |
| AUTH-T10 | Bearer header format | Correctly parses `Bearer <token>` |
### 8.3 OAuth Flow
| ID | Test | Description |
|----|------|-------------|
| AUTH-T11 | GitHub OAuth success | Code exchanged, user created/found, session set, redirected |
| AUTH-T12 | Gitea OAuth success | Same as above for Gitea |
| AUTH-T13 | OIDC OAuth success | Uses discovery document, same flow |
| AUTH-T14 | OAuth invalid code | Returns error, redirects to login with error message |
| AUTH-T15 | OAuth provider down | Returns 502 with friendly error |
| AUTH-T16 | Existing user re-login | Finds existing user via oauth_accounts, creates new session |
| 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
| 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 |
### 8.5 Invite Flow
| ID | Test | Description |
|----|------|-------------|
| AUTH-T22 | Valid invite → login → join | Full invite acceptance flow works |
| AUTH-T23 | Expired invite | Shows error page |
| AUTH-T24 | Exhausted invite | Shows error page |
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
### 8.6 Reverse Proxy
| ID | Test | Description |
|----|------|-------------|
| AUTH-T26 | API route proxied | /api/channels → forwarded to API service |
| AUTH-T27 | Web route proxied | / → forwarded to Web SM |
| AUTH-T28 | TUI route proxied | /tui/sse → forwarded to TUI SM |
| AUTH-T29 | SSE streaming | SSE response streamed without buffering |
| AUTH-T30 | Target service down | Returns 502 |
| 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
| ID | Test | Description |
|----|------|-------------|
| AUTH-T33 | Under limit | Requests succeed normally |
| AUTH-T34 | Over limit | Returns 429 with Retry-After header |
| 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
| 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
| ID | Test | Description |
|----|------|-------------|
| AUTH-T39 | Health check | GET /health returns 200 with service status |
| AUTH-T40 | Health check (DB down) | Returns 503 with degraded status |
---
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
```
┌──────────────────────────────────────┐
│ │
│ ┌──────────────┐ │
│ │ ajet chat │ │
│ └──────────────┘ │
│ │
│ Sign in to continue │
│ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with GitHub │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with Gitea │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ ◉ Continue with SSO │ │ ← only if OIDC configured
│ └──────────────────────────┘ │
│ │
│ ─── or accepting invite ─── │ ← only if invite code present
│ Joining: My Team │
│ │
└──────────────────────────────────────┘
```
+432
View File
@@ -0,0 +1,432 @@
# PRD: CLI & TUI Client
**Module:** `cli/` | **Namespace:** `ajet.chat.cli.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The CLI module is a single executable (babashka via bbin) that provides two modes:
- **CLI Mode:** Stateless one-shot commands for scripting and quick interactions
- **TUI Mode:** Full interactive terminal application with split panes, inline images, markdown rendering, and mouse support (built on clojure-tui)
Both modes connect through the Auth Gateway.
## 2. Installation & Config
### 2.1 Installation
```bash
bbin install ajet-chat # installs as `ajet` command
```
### 2.2 Config Location
```
~/.config/ajet-chat/
├── config.edn # server URL, default community, preferences
├── session.edn # session token (encrypted at rest)
└── state.edn # TUI state: last community, last channel per community
```
### 2.3 Config Shape
```clojure
;; config.edn
{:server-url "https://chat.example.com"
:default-community "my-team" ;; slug
:tui {:theme :dark ;; :dark | :light
:image-viewer :timg ;; :timg | :sixel | :none
:mouse true
:timestamps :relative ;; :relative | :absolute | :none
:notifications :bell}} ;; :bell | :none
;; session.edn (encrypted)
{:token "base64url-session-token"
:user-id "uuid"
:username "alice"
:expires-at "2026-03-19T..."}
```
## 3. CLI Mode
### 3.1 Command Syntax
```
ajet <command> [options] [args]
```
### 3.2 Commands
#### Authentication
```bash
ajet login # Interactive OAuth login (opens browser)
ajet login --token <api-token> # Login with API token (for scripts)
ajet logout # Clear session
ajet whoami # Show current user info
```
#### Channels & Communities
```bash
ajet communities # List communities
ajet channels # List channels in default community
ajet channels --community <slug> # List channels in specific community
ajet channels --join <name> # Join a public channel
ajet channels --leave <name> # Leave a channel
```
#### Messages
```bash
ajet read <channel> # Read last 50 messages in channel
ajet read <channel> --limit 100 # Custom limit
ajet read <channel> --before <id> # Older messages (pagination)
ajet read <channel> --thread <id> # Read thread replies
ajet send <channel> <message> # Send message
ajet send <channel> --stdin # Read message from stdin (piping)
ajet send <channel> --image <path> # Send message with image
ajet edit <message-id> <new-text> # Edit a message
ajet delete <message-id> # Delete a message
```
#### DMs
```bash
ajet dms # List DM channels
ajet dm <username> <message> # Send DM (creates if needed)
ajet dm <username> --read # Read DM conversation
```
#### Notifications
```bash
ajet notifications # List unread notifications
ajet notifications --all # List all notifications
ajet notifications --mark-read # Mark all as read
```
#### Search
```bash
ajet search <query> # Global search
ajet search <query> --channel <ch> # Search in specific channel
ajet search <query> --from <user> # Search by author
ajet search <query> --type messages # Filter by type
```
#### Presence & Status
```bash
ajet status # Show current status
ajet status "Working on backend" # Set status
ajet who # Show online users in current community
```
#### Invites
```bash
ajet invite create # Generate invite link
ajet invite create --max-uses 10 # With use limit
ajet invite list # List active invites
ajet invite revoke <id> # Revoke an invite
```
#### Admin
```bash
ajet config # Show current config
ajet config set <key> <value> # Set config value
ajet config server <url> # Set server URL
```
### 3.3 Output Formats
**Default (human-readable):**
```
$ ajet read general --limit 3
#general — My Team
alice 10:30 AM
hello everyone!
bob 10:31 AM
hey! check this out
[image: screenshot.png]
carol 10:45 AM
@bob nice work! 👍
```
**JSON output (for scripting):**
```bash
ajet read general --json | jq '.messages[].body_md'
```
**Pipe-friendly:**
```bash
echo "Build #42 passed!" | ajet send devops --stdin
git log --oneline -5 | ajet send backend --stdin
```
### 3.4 OAuth Login Flow (CLI)
```
1. ajet login
2. CLI starts a temporary local HTTP server (localhost:random-port)
3. Opens browser to: https://chat.example.com/auth/login?redirect=http://localhost:{port}/callback
4. User completes OAuth in browser
5. Auth GW redirects to localhost callback with session token
6. CLI captures token, saves to ~/.config/ajet-chat/session.edn
7. Prints "Logged in as alice"
```
**Fallback (no browser):**
```
$ ajet login
Opening browser...
If your browser didn't open, visit:
https://chat.example.com/auth/login?cli=true
Then paste the token here: _
```
## 4. TUI Mode
### 4.1 Launch
```bash
ajet tui # Open TUI (interactive mode)
ajet tui --community <slug> # Open to specific community
ajet tui --channel <name> # Open to specific channel
```
### 4.2 Layout (Rich TUI with clojure-tui)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ajet chat │ My Team alice ● online │
├──────────────────┬──────────────────────────────────────────────────────────┤
│ Communities │ #general — Welcome to My Team! │
│ ● My Team │──────────────────────────────────────────────────────────│
│ Open Source │ │
│ │ alice 10:30 │
│ ──────────────── │ hello everyone! │
│ ▼ GENERAL │ │
│ #general 3 │ bob 10:31 │
│ #random │ hey! check out this screenshot │
│ │ ┌─────────────────────────────┐ │
│ ▼ DEV │ │ [inline image via timg] │ │
│ #backend ● │ │ │ │
│ #frontend │ └──────────────────────────────┘ │
│ │ │
│ ──────────────── │ carol 10:45 │
│ DMs │ @bob nice work! 👍 │
│ alice ● │ 💬 2 replies │
│ bob │ │
│ group (3) 2 │ ── Load older ── │
│ │ │
│ ──────────────── │──────────────────────────────────────────────────────────│
│ 🔍 /search │ bob is typing... │
│ │ > hello world_ │
│ │ │
├──────────────────┴──────────────────────────────────────────────────────────┤
│ [/help] Ctrl+K: search Ctrl+N: next channel Ctrl+P: prev Ctrl+Q: quit │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 4.3 TUI Panes
| Pane | Description |
|------|-------------|
| Header | App name, community name, user status |
| Sidebar | Communities, categories, channels, DMs with unread indicators |
| Channel Header | Channel name + topic |
| Message List | Scrollable message history with rendered markdown |
| Input Area | Message composition with autocomplete |
| Status Bar | Keybindings, connection status |
| Thread Panel | Overlays right side when viewing thread (like web) |
### 4.4 Keyboard Navigation
| Key | Action |
|-----|--------|
| `Enter` | Send message |
| `Shift+Enter` / `Alt+Enter` | Newline in input |
| `Ctrl+K` | Open search |
| `Ctrl+N` | Next channel (down in sidebar) |
| `Ctrl+P` | Previous channel (up in sidebar) |
| `Ctrl+Q` | Quit |
| `Ctrl+T` | Open thread for selected message |
| `Ctrl+R` | React to selected message |
| `Ctrl+E` | Edit selected message |
| `Ctrl+D` | Delete selected message (with confirmation) |
| `Tab` | Switch focus: input ↔ message list ↔ sidebar |
| `↑/↓` | Navigate messages (when message list focused) |
| `j/k` | Vim-style navigate messages |
| `PgUp/PgDn` | Scroll message history (loads older on PgUp at top) |
| `Esc` | Close thread panel / cancel edit / blur search |
| `/` | Start slash command (when input focused) |
| `@` | Start mention autocomplete |
| `#` | Start channel autocomplete |
| `Mouse click` | Select channel, message, or button |
| `Mouse scroll` | Scroll message list |
### 4.5 Inline Image Rendering
**Via timg (preferred):**
- Detect terminal capabilities (sixel, kitty graphics, iterm2)
- Use timg to render images inline in the message list
- Fallback: show `[image: filename.png]` placeholder with URL
**Image display flow:**
1. Message with attachment arrives
2. TUI SM provides image URL in event
3. TUI client downloads image in background
4. Renders inline using timg/sixel
5. If terminal doesn't support graphics: show filename + dimensions
### 4.6 Markdown Rendering (ANSI)
| Markdown | ANSI Rendering |
|----------|---------------|
| `**bold**` | ANSI bold |
| `*italic*` | ANSI italic (or dim on unsupported terminals) |
| `~~strike~~` | ANSI strikethrough |
| `__underline__` | ANSI underline |
| `` `code` `` | Dim background or different color |
| Code block | Box-drawing border + syntax highlighting (ANSI colors) |
| `> quote` | Vertical bar prefix + dim text |
| `\|\|spoiler\|\|` | Hidden text (press Enter on selected to reveal) |
| Links | Underlined + hyperlink escape (OSC 8) |
| Emoji | Unicode emoji (terminal must support) |
| `@mention` | Highlighted (bold + color) |
| `#channel` | Highlighted (different color), clickable |
### 4.7 SSE Connection
TUI client connects to TUI SM via SSE:
```
GET https://chat.example.com/tui/sse/events?community_id=<uuid>
Cookie: ajet_session=<token>
Accept: text/event-stream
Last-Event-ID: <id> (for reconnection)
```
- On connect: receive `init` event, populate sidebar + channel list
- On events: update message list, sidebar badges, typing indicators
- On disconnect: show "Reconnecting..." in status bar, auto-retry with backoff
### 4.8 Notifications in TUI
- Terminal bell (`\a`) on new @mention or DM (configurable)
- Unread counts in sidebar (same as web)
- Notification list accessible via slash command `/notifications`
## 5. Test Cases
### 5.1 CLI Authentication
| ID | Test | Description |
|----|------|-------------|
| CLI-T1 | Login via OAuth | Opens browser, captures callback, saves token |
| CLI-T2 | Login via API token | `--token` flag saves token directly |
| CLI-T3 | Logout clears session | Token removed from config |
| CLI-T4 | Whoami shows user | Prints username, display name, communities |
| 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
| ID | Test | Description |
|----|------|-------------|
| CLI-T7 | List communities | Shows user's communities |
| CLI-T8 | List channels | Shows channels for default community |
| CLI-T9 | Read messages | Displays formatted messages with avatars, timestamps |
| CLI-T10 | Read with pagination | `--before` flag returns older messages |
| CLI-T11 | Read thread | `--thread` flag shows thread replies |
| CLI-T12 | Send message | Message appears in channel |
| CLI-T13 | Send via stdin | `echo "msg" \| ajet send general --stdin` works |
| CLI-T14 | Send with image | Image uploaded, message sent with attachment |
| CLI-T15 | Edit message | Message updated within 1-hour window |
| CLI-T16 | Edit after window | Returns error "edit window expired" |
| CLI-T17 | Delete message | Message deleted with confirmation prompt |
| CLI-T18 | List DMs | Shows DM conversations |
| CLI-T19 | Send DM | Creates DM if needed, sends message |
| CLI-T20 | List notifications | Shows unread notifications |
| CLI-T21 | Mark notifications read | All notifications marked read |
| CLI-T22 | Search | Returns matching results with context |
| CLI-T23 | Search with filters | --channel, --from, --type filters work |
| CLI-T24 | Set status | Status updated |
| CLI-T25 | Who online | Shows online users list |
| CLI-T26 | Create invite | Returns invite link |
| CLI-T27 | JSON output | `--json` flag outputs raw JSON |
| CLI-T28 | Unknown command | Prints help with suggestion |
| CLI-T29 | No arguments | Prints usage/help |
### 5.3 TUI Launch & Layout
| ID | Test | Description |
|----|------|-------------|
| TUI-T1 | TUI launches | Full layout renders with all panes |
| TUI-T2 | Sidebar populated | Communities, channels, DMs shown from init event |
| TUI-T3 | Messages loaded | Active channel messages displayed |
| TUI-T4 | Unread badges | Channels with unread messages show count |
| 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
| ID | Test | Description |
|----|------|-------------|
| TUI-T7 | Ctrl+N next channel | Moves to next channel in sidebar |
| TUI-T8 | Ctrl+P prev channel | Moves to previous channel |
| TUI-T9 | Tab focus cycle | Focus cycles: input → messages → sidebar |
| TUI-T10 | Click channel | Mouse click switches to channel |
| TUI-T11 | Arrow keys in messages | Navigate between messages |
| TUI-T12 | j/k vim navigation | Vim-style navigation in message list |
| TUI-T13 | PgUp loads older | Scrolling up loads older messages |
| TUI-T14 | Mouse scroll | Mouse scroll in message list |
| 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
| ID | Test | Description |
|----|------|-------------|
| TUI-T17 | Send message | Enter sends, message appears in list |
| TUI-T18 | Multiline input | Alt+Enter adds newline |
| TUI-T19 | @mention autocomplete | Typing @ shows dropdown, Tab selects |
| TUI-T20 | #channel autocomplete | Typing # shows dropdown |
| TUI-T21 | /slash command | Typing / shows command list |
| TUI-T22 | Edit message | Ctrl+E on selected → inline edit → Enter to save |
| TUI-T23 | Delete message | Ctrl+D on selected → confirmation → deleted |
| TUI-T24 | React to message | Ctrl+R → emoji input → reaction added |
| TUI-T25 | Open thread | Ctrl+T on message with replies → thread panel |
| 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
| ID | Test | Description |
|----|------|-------------|
| TUI-T28 | New message arrives | SSE event → message appears at bottom |
| TUI-T29 | Typing indicator | "bob is typing..." shows below message list |
| TUI-T30 | Presence updates | Online dot changes when user goes offline |
| TUI-T31 | Unread updates | New message in other channel updates sidebar badge |
| TUI-T32 | SSE reconnect | Connection lost → "Reconnecting..." → auto-reconnects |
| TUI-T33 | Bell notification | Terminal bell on @mention or DM |
### 5.7 TUI Rendering
| ID | Test | Description |
|----|------|-------------|
| TUI-T34 | Markdown bold | `**text**` renders as ANSI bold |
| TUI-T35 | Markdown italic | `*text*` renders as ANSI italic |
| TUI-T36 | Code block | Fenced code block renders with border + syntax colors |
| TUI-T37 | Inline code | Backtick code renders with different color |
| TUI-T38 | Block quote | `> text` renders with vertical bar prefix |
| TUI-T39 | Spoiler text | `\|\|text\|\|` renders hidden until Enter pressed |
| TUI-T40 | Inline image (timg) | Image renders inline via timg when supported |
| TUI-T41 | Image fallback | `[image: file.png 800x600]` when graphics not supported |
| TUI-T42 | Mention highlight | @alice renders bold + colored |
| TUI-T43 | Channel link | #general renders colored and navigable |
| TUI-T44 | Hyperlinks | URLs render as OSC 8 hyperlinks when terminal supports |
| 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
| ID | Test | Description |
|----|------|-------------|
| TUI-T47 | Send fails | Error shown inline below input |
| TUI-T48 | API timeout | Status bar shows warning, retries |
| TUI-T49 | Ctrl+Q quit | Clean shutdown: close SSE, save state |
| TUI-T50 | SIGINT handling | Ctrl+C during TUI gracefully exits |
+42
View File
@@ -0,0 +1,42 @@
# PRD: Mobile Client
**Module:** `mobile/` | **Namespace:** TBD
**Status:** Deferred (v2+) | **Last updated:** 2026-02-17
---
## 1. Overview
Mobile client is deferred to a future version. The architecture supports it via the same Auth GW + SSE pattern used by the web and TUI clients.
## 2. Options to Evaluate
| Option | Pros | Cons |
|--------|------|------|
| **PWA (web-sm)** | Zero extra work — web UI works on mobile browsers. Add manifest + service worker for installability. | Limited push notifications, no native feel. |
| **ClojureDart** | Stay in Clojure ecosystem. Compiles to Dart/Flutter. | Immature ecosystem, debugging harder. |
| **React Native + API** | Mature ecosystem, native feel, large talent pool. | Separate language (JS/TS), not Clojure. |
## 3. Recommended Path
Start with **PWA** (progressive web app from web-sm) for mobile access in v1. Evaluate native options for v2 based on user feedback.
**PWA requirements (P2, not v1):**
- Add web app manifest to web-sm
- Add service worker for offline caching
- Responsive CSS for mobile viewports
- Touch-friendly interaction sizes
## 4. Architecture (Future Native)
Same pattern as TUI client:
```
Mobile App ←─ SSE (JSON) ──→ Mobile SM ──→ NATS
←─ HTTP POST ───→ ──→ API
```
A dedicated Mobile SM would handle push notification routing and mobile-specific optimizations (battery-aware polling, data compression).
## 5. No Test Cases
No test cases defined for the mobile client as it is deferred. Test cases will be created when a specific implementation approach is chosen.
+300
View File
@@ -0,0 +1,300 @@
# PRD: Shared Module
**Module:** `shared/` | **Namespace:** `ajet.chat.shared.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The shared module provides foundational libraries used by all other services: database access, event bus (NATS), API client SDK, data schemas, and common protocols. It has no main entry point — it's a library dependency.
## 2. Components
### 2.1 Database Layer (`ajet.chat.shared.db`)
**Purpose:** PostgreSQL connection management, query helpers, migration runner.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| DB-1 | Create HikariCP connection pool from config map | P0 |
| DB-2 | Provide `with-transaction` macro wrapping `next.jdbc/with-transaction` | P0 |
| DB-3 | Provide `execute!`, `execute-one!`, `plan` wrappers that accept HoneySQL maps | P0 |
| DB-4 | Run Migratus migrations on startup (configurable: auto or manual) | P0 |
| DB-5 | Support read-only datasource config for future read replicas | P2 |
| DB-6 | Log slow queries (> configurable threshold, default 500ms) | P1 |
**Config shape:**
```clojure
{:db {:host "localhost"
:port 5432
:dbname "ajet_chat"
:user "ajet"
:password "..."
:pool-size 10
:migrations {:enabled true :location "migrations"}}}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| DB-T1 | Pool creation with valid config | Unit | `make-datasource` returns a pooled datasource |
| DB-T2 | Pool creation with invalid config | Unit | Throws clear error on bad host/port/creds |
| DB-T3 | HoneySQL map execution | Integration | `execute!` with `{:select [:*] :from [:users]}` returns rows |
| DB-T4 | Transaction commit | Integration | Writes inside `with-transaction` are visible after commit |
| DB-T5 | Transaction rollback | Integration | Exception inside `with-transaction` rolls back all writes |
| DB-T6 | Migration forward | Integration | `migrate!` applies pending migrations, creates tables |
| DB-T7 | Migration rollback | Integration | `rollback!` reverses last migration |
| DB-T8 | Migration idempotency | Integration | Running `migrate!` twice is a no-op when up to date |
| DB-T9 | Slow query logging | Integration | Query taking > threshold triggers log warning |
| DB-T10 | Connection pool exhaustion | Integration | Requests beyond pool-size block then timeout with clear error |
---
### 2.2 EventBus (`ajet.chat.shared.eventbus`)
**Purpose:** NATS pub/sub abstraction with JetStream support for durable subscriptions.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| EB-1 | `connect!` — create NATS connection from config | P0 |
| EB-2 | `publish!` — publish event to a subject (JSON-encoded) | P0 |
| EB-3 | `subscribe!` — subscribe to subject pattern, return subscription handle | P0 |
| EB-4 | `unsubscribe!` — close subscription by handle | P0 |
| EB-5 | `close!` — close NATS connection | P0 |
| EB-6 | Events are EDN maps serialized as JSON on the wire | P0 |
| EB-7 | Support wildcard subjects (`chat.events.*`) | P0 |
| EB-8 | JetStream durable consumer for SM replay on reconnect | P1 |
| EB-9 | Auto-reconnect on NATS connection loss with backoff | P1 |
| EB-10 | Connection health check (ping/pong) | P1 |
**Subject hierarchy:**
```
chat.events.{community-id} — channel message CRUD, channel events, reactions
chat.dm.{channel-id} — DM message events
chat.typing.{community-id}.{channel-id} — channel typing indicators
chat.typing.dm.{channel-id} — DM typing indicators
chat.presence.{community-id} — heartbeat/presence events
chat.notifications.{user-id} — per-user notification delivery
```
**Event envelope:**
```clojure
{:type :message/created ;; keyword
:id "uuid" ;; event ID for dedup
:ts "2026-02-17T..." ;; ISO 8601
:payload {:message-id "uuid"
:channel-id "uuid"
:user-id "uuid"
:body-md "hello"}}
```
**Event types:**
```
:message/created, :message/edited, :message/deleted
:reaction/added, :reaction/removed
:channel/created, :channel/updated, :channel/deleted
:member/joined, :member/left, :member/kicked, :member/banned
:typing/start, :typing/stop
:presence/online, :presence/offline
:notification/new
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| EB-T1 | Connect to NATS | Integration | `connect!` returns active connection |
| EB-T2 | Connect with bad URL | Unit | Throws clear error |
| EB-T3 | Publish and subscribe | Integration | Published event is received by subscriber |
| EB-T4 | Wildcard subscribe | Integration | `chat.events.*` receives events for any community |
| EB-T5 | Unsubscribe stops delivery | Integration | After `unsubscribe!`, no more events received |
| EB-T6 | Multiple subscribers | Integration | Two subscribers on same subject both receive event |
| EB-T7 | JSON roundtrip | Unit | EDN map → JSON → EDN map preserves all fields |
| EB-T8 | Event envelope validation | Unit | Missing `:type` or `:payload` throws |
| EB-T9 | JetStream replay | Integration | Durable consumer replays missed events on reconnect |
| EB-T10 | Auto-reconnect | Integration | Subscriber resumes after NATS restart |
| EB-T11 | Close connection | Integration | `close!` disconnects cleanly, subsequent publish throws |
---
### 2.3 API Client SDK (`ajet.chat.shared.api-client`)
**Purpose:** HTTP client for internal (SM → API) and external (CLI → Auth GW) communication.
**Status:** Substantially implemented (219 lines). Needs expansion.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| AC-1 | Context-based design: `{:base-url :auth-token :trace-id}` | P0 |
| AC-2 | All CRUD for: channels, messages, reactions, DMs, users, notifications | P0 |
| AC-3 | Heartbeat endpoint | P0 |
| AC-4 | Search endpoint (messages, channels, users) | P0 |
| AC-5 | Invite management (create link, direct invite, list, revoke) | P0 |
| AC-6 | Slash command dispatch | P0 |
| AC-7 | Community CRUD (create, update, list, get) | P0 |
| AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 |
| AC-9 | Webhook management (create, delete, list) | P1 |
| AC-10 | Cursor-based pagination support (`?after=<uuid>&limit=N`) | P0 |
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
| AC-12 | Request timeout (configurable, default 10s) | P0 |
| AC-13 | Structured error responses (`ex-info` with status + body) | P0 |
| AC-14 | File upload (multipart) for image attachments | P0 |
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| AC-T1 | Build headers with token | Unit | `build-headers` includes Authorization + trace ID |
| AC-T2 | Build headers without token | Unit | No Authorization header when token nil |
| AC-T3 | JSON encode/decode roundtrip | Unit | Clojure map survives JSON serialization |
| AC-T4 | 4xx response throws ex-info | Unit | `check-response!` throws with status + body |
| AC-T5 | 5xx response throws ex-info | Unit | Includes status code and response body |
| AC-T6 | get-channels returns list | Integration | Returns vector of channel maps |
| AC-T7 | send-message creates message | Integration | POST returns created message with ID |
| AC-T8 | edit-message within window | Integration | PUT succeeds within 1-hour window |
| AC-T9 | edit-message after window | Integration | PUT returns 403 after 1-hour window |
| AC-T10 | delete-message own | Integration | DELETE succeeds for message author |
| AC-T11 | delete-message other (non-admin) | Integration | DELETE returns 403 |
| AC-T12 | cursor pagination | Integration | `?after=uuid&limit=20` returns next page |
| AC-T13 | search messages | Integration | Returns matching messages across accessible channels |
| AC-T14 | retry on 503 | Unit | Retries up to 3 times with backoff |
| AC-T15 | timeout handling | Unit | Throws after configured timeout |
| AC-T16 | image upload multipart | Integration | File uploaded, attachment record created |
---
### 2.4 Schemas & Validation (`ajet.chat.shared.schema`)
**Purpose:** Data shape definitions and validation for all entities.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| SC-1 | Define specs/schemas for all DB entities | P0 |
| SC-2 | Request validation for API endpoints | P0 |
| SC-3 | Event payload validation | P1 |
| SC-4 | Use `clojure.spec.alpha` or Malli (TBD) | P0 |
**Entities to define:**
```
community: {:id uuid, :name string, :slug string, :created-at inst}
user: {:id uuid, :username string, :display-name string, :email string, :avatar-url string?, :status-text string?, :created-at inst}
oauth-account: {:id uuid, :user-id uuid, :provider string, :provider-user-id string}
session: {:id uuid, :user-id uuid, :token-hash string, :expires-at inst}
api-user: {:id uuid, :name string, :community-id uuid, :scopes [string]}
api-token: {:id uuid, :api-user-id uuid, :token-hash string, :scopes [string], :expires-at inst}
channel: {:id uuid, :community-id uuid?, :name string, :type enum, :visibility enum, :topic string?, :category-id uuid?}
channel-category:{:id uuid, :community-id uuid, :name string, :position int}
community-member:{:community-id uuid, :user-id uuid, :role enum, :nickname string?, :avatar-url string?}
channel-member: {:channel-id uuid, :user-id uuid, :joined-at inst}
message: {:id uuid, :channel-id uuid, :user-id uuid, :parent-id uuid?, :body-md string, :created-at inst, :edited-at inst?}
attachment: {:id uuid, :message-id uuid, :filename string, :content-type string, :size-bytes int, :storage-key string}
reaction: {:message-id uuid, :user-id uuid, :emoji string}
webhook: {:id uuid, :community-id uuid, :channel-id uuid, :name string, :avatar-url string?, :token-hash 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?}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| SC-T1 | Valid community passes validation | Unit | All required fields present and correct types |
| SC-T2 | Missing required field fails | Unit | Omitting `:name` from community fails validation |
| SC-T3 | Invalid UUID format fails | Unit | String "not-a-uuid" in UUID field fails |
| SC-T4 | Enum validation | Unit | Channel `:type` must be one of `#{:text :voice :dm :group-dm}` |
| SC-T5 | Optional fields accepted when nil | Unit | `:topic nil` passes for channel |
| SC-T6 | Message body not empty | Unit | Empty string body fails validation |
| SC-T7 | Slug format validation | Unit | Community slug must match `[a-z0-9-]+` |
| SC-T8 | Mention storage format | Unit | `@<user:uuid>` parses correctly to `{:target-type :user :target-id uuid}` |
| SC-T9 | Event envelope validation | Unit | Event with valid type + payload passes |
---
### 2.5 Mention Parser (`ajet.chat.shared.mentions`)
**Purpose:** Parse and render mention/channel-link syntax in message bodies.
**Storage format:** Raw markdown with embedded references:
- `@<user:550e8400-e29b-41d4-a716-446655440000>` — user mention
- `@<here>`@here mention (notify all online in channel)
- `#<channel:550e8400-e29b-41d4-a716-446655440000>` — channel link
**Rendered output:** (resolved at display time by SMs)
- `@someUser` — rendered with user's display name
- `@here` — rendered as-is
- `#general` — rendered with channel name
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| MP-1 | Parse `@<user:uuid>` from message body, extract UUIDs | P0 |
| MP-2 | Parse `@<here>` from message body | P0 |
| MP-3 | Parse `#<channel:uuid>` from message body | P0 |
| MP-4 | Return list of `{:type :target-id}` for mention record creation | P0 |
| MP-5 | Render mentions by replacing references with display names | P0 |
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| MP-T1 | Parse single user mention | Unit | `"hello @<user:abc-123>"``[{:type :user :id "abc-123"}]` |
| MP-T2 | Parse multiple mentions | Unit | Two user mentions in one message → both extracted |
| MP-T3 | Parse @here | Unit | `"@<here> look at this"``[{:type :here}]` |
| MP-T4 | Parse channel link | Unit | `"see #<channel:def-456>"``[{:type :channel :id "def-456"}]` |
| MP-T5 | Parse mixed mentions | Unit | User + here + channel in one message → all three extracted |
| MP-T6 | No mentions returns empty | Unit | `"just a normal message"``[]` |
| MP-T7 | Mention inside code block ignored | Unit | `` `@<user:abc>` `` → `[]` (code blocks are not parsed) |
| MP-T8 | Render user mention | Unit | Replace `@<user:abc>` with `@alice` given lookup map |
| MP-T9 | Render channel link | Unit | Replace `#<channel:def>` with `#general` given lookup map |
| MP-T10 | Render unknown user | Unit | `@<user:unknown>` renders as `@unknown-user` |
---
### 2.6 Markdown Processor (`ajet.chat.shared.markdown`)
**Purpose:** Parse and render Discord-flavor markdown to HTML (for web) and ANSI (for TUI).
**Supported syntax:**
- **Bold:** `**text**`
- *Italic:* `*text*` or `_text_`
- ~~Strikethrough:~~ `~~text~~`
- __Underline:__ `__text__` (Discord extension)
- `Inline code`: `` `code` ``
- Code blocks: ` ```lang\ncode\n``` ` with syntax highlighting
- Spoilers: `||hidden text||`
- Block quotes: `> text`
- Links: `[text](url)` — auto-link bare URLs
- Emoji shortcodes: `:smile:` → rendered emoji
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| MD-T1 | Bold rendering | Unit | `**bold**``<strong>bold</strong>` |
| MD-T2 | Italic rendering | Unit | `*italic*``<em>italic</em>` |
| MD-T3 | Strikethrough | Unit | `~~strike~~``<del>strike</del>` |
| MD-T4 | Underline | Unit | `__underline__``<u>underline</u>` |
| MD-T5 | Inline code | Unit | `` `code` `` → `<code>code</code>` |
| MD-T6 | Fenced code block | Unit | Triple backtick block → `<pre><code>` with language class |
| MD-T7 | Spoiler | Unit | `\|\|spoiler\|\|` → `<span class="spoiler">spoiler</span>` |
| MD-T8 | Block quote | Unit | `> quote` → `<blockquote>quote</blockquote>` |
| MD-T9 | Auto-link URL | Unit | Bare `https://example.com` → clickable link |
| MD-T10 | Nested formatting | Unit | `**bold and *italic***` renders correctly |
| MD-T11 | XSS prevention | Unit | `<script>alert(1)</script>` is escaped, not executed |
| MD-T12 | Emoji shortcode | Unit | `:smile:` → Unicode emoji character |
| 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) |
+178
View File
@@ -0,0 +1,178 @@
# PRD: TUI Session Manager
**Module:** `tui-sm/` | **Namespace:** `ajet.chat.tui-sm.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders.
Unlike Web SM (which sends HTML fragments), TUI SM sends **JSON-structured events** over SSE. The TUI client handles all rendering.
## 2. Architecture
```
TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe)
←─ HTTP POST (signals) ──→ ──→ API (HTTP, data)
```
## 3. SSE Event Protocol
### 3.1 Connection
**Endpoint:** `GET /tui/sse/events`
**Headers:**
- `Accept: text/event-stream`
- `X-User-Id`, `X-Trace-Id` (injected by Auth GW)
**Query params:**
- `community_id=<uuid>` — active community (optional, can start with DMs)
- `last_event_id=<id>` — for reconnection replay
### 3.2 SSE Event Format
```
event: <event-type>
id: <sequential-id>
data: <json-payload>
```
### 3.3 Event Types
| Event Type | Payload | Description |
|------------|---------|-------------|
| `init` | `{communities, channels, dms, user}` | Initial state on connect |
| `message.new` | `{message}` | New message in subscribed channel |
| `message.edit` | `{message}` | Message edited |
| `message.delete` | `{message_id, channel_id}` | Message deleted |
| `reaction.add` | `{message_id, emoji, user_id}` | Reaction added |
| `reaction.remove` | `{message_id, emoji, user_id}` | Reaction removed |
| `typing.start` | `{channel_id, user_id, username}` | User started typing |
| `typing.stop` | `{channel_id, user_id}` | User stopped typing |
| `presence.update` | `{user_id, status}` | Online/offline change |
| `channel.new` | `{channel}` | New channel in community |
| `channel.update` | `{channel}` | Channel updated (topic, name) |
| `channel.delete` | `{channel_id}` | Channel deleted |
| `member.join` | `{channel_id, user}` | User joined channel |
| `member.leave` | `{channel_id, user_id}` | User left channel |
| `notification` | `{notification}` | New notification for user |
| `unread.update` | `{channel_id, count, mentions}` | Unread count changed |
| `ping` | `{}` | Keepalive (every 30s) |
### 3.4 Init Event
Sent immediately after SSE connection established:
```json
{
"communities": [
{"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"}
],
"channels": {
"community-uuid": [
{"id": "uuid", "name": "general", "type": "text", "category": "General",
"unread_count": 3, "mention_count": 1}
]
},
"dms": [
{"id": "uuid", "type": "dm", "members": [...], "unread_count": 0}
],
"user": {"id": "uuid", "username": "alice", "display_name": "Alice"}
}
```
## 4. Client → Server Signals (HTTP POST)
| Action | Endpoint | Payload |
|--------|----------|---------|
| Send message | `POST /tui/messages` | `{channel_id, body_md, parent_id?}` |
| Edit message | `POST /tui/messages/:id/edit` | `{body_md}` |
| Delete message | `POST /tui/messages/:id/delete` | — |
| Add reaction | `POST /tui/reactions` | `{message_id, emoji}` |
| Remove reaction | `POST /tui/reactions/remove` | `{message_id, emoji}` |
| Switch channel | `POST /tui/navigate` | `{channel_id, community_id?}` |
| Mark read | `POST /tui/read` | `{channel_id, message_id}` |
| Typing | `POST /tui/typing` | `{channel_id}` |
| Heartbeat | `POST /tui/heartbeat` | — |
| Fetch messages | `GET /tui/messages` | `?channel_id=<uuid>&before=<uuid>&limit=50` |
| Search | `GET /tui/search` | `?q=<query>&type=<type>` |
| Slash command | `POST /tui/command` | `{command, channel_id, community_id}` |
All responses are JSON.
## 5. Connection Tracking
```clojure
{user-id {:sse-channel <http-kit-channel>
:active-community uuid-or-nil
:active-channel uuid-or-nil
:nats-subs [sub-handles...]
:last-event-id int
:connected-at instant}}
```
- On connect: send `init` event, subscribe to user's NATS subjects
- On disconnect: unsubscribe from NATS, clean up state
- On community switch: update NATS subscriptions
- Keepalive pings every 30 seconds to detect dead connections
## 6. Presence Batching
TUI SM buffers presence events to avoid flooding clients:
- Collect heartbeat events over 60-second windows
- On flush: diff with previous state, send only changes
- Typing indicators sent immediately (latency-sensitive)
## 7. Test Cases
### 7.1 SSE Connection
| ID | Test | Description |
|----|------|-------------|
| TSM-T1 | SSE connect | GET /tui/sse/events returns text/event-stream |
| TSM-T2 | Init event sent | First event is `init` with communities, channels, DMs, user |
| TSM-T3 | NATS subscribed on connect | Server subscribes to user's community subjects |
| TSM-T4 | Ping keepalive | Ping event sent every 30 seconds |
| 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
| ID | Test | Description |
|----|------|-------------|
| TSM-T7 | New message event | Message from NATS → SSE `message.new` event to client |
| TSM-T8 | Message edit event | Edit from NATS → SSE `message.edit` event |
| TSM-T9 | Message delete event | Delete from NATS → SSE `message.delete` event |
| TSM-T10 | Reaction events | Add/remove from NATS → SSE reaction events |
| TSM-T11 | Typing indicator | Typing from NATS → SSE `typing.start` event |
| TSM-T12 | Typing auto-expire | `typing.stop` sent after 15 seconds of silence |
| TSM-T13 | Presence update | Batched presence diffs sent every 60 seconds |
| TSM-T14 | Channel events | New/update/delete channel events forwarded |
| 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
| ID | Test | Description |
|----|------|-------------|
| TSM-T17 | Send message | POST /tui/messages → API call → success response |
| TSM-T18 | Edit message | POST /tui/messages/:id/edit → API call → success |
| TSM-T19 | Delete message | POST /tui/messages/:id/delete → API call → success |
| TSM-T20 | Switch channel | POST /tui/navigate → NATS sub update → returns channel messages |
| TSM-T21 | Fetch older messages | GET /tui/messages with before cursor → paginated results |
| TSM-T22 | Search | GET /tui/search → API search → JSON results |
| TSM-T23 | Slash command | POST /tui/command → API command → result |
| TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API |
### 7.4 Edge Cases
| ID | Test | Description |
|----|------|-------------|
| TSM-T25 | Multiple TUI clients | Same user, two connections — both receive events |
| TSM-T26 | API unavailable | Returns 502, SSE stays open |
| TSM-T27 | NATS unavailable | SSE stays open, events resume when NATS recovers |
| TSM-T28 | Event filtering | Events for non-subscribed channels not forwarded |
| TSM-T29 | DM events | DM messages forwarded via `chat.dm.{channel-id}` subscription |
| TSM-T30 | Large init payload | Init event with many channels/DMs serializes correctly |
+441
View File
@@ -0,0 +1,441 @@
# PRD: Web Session Manager
**Module:** `web-sm/` | **Namespace:** `ajet.chat.web.*`
**Status:** v1 | **Last updated:** 2026-02-17
---
## 1. Overview
The Web Session Manager serves the browser-based chat UI. It renders HTML via Hiccup, uses Datastar for SSE-driven reactivity (server pushes HTML fragments to update the page), subscribes to NATS for real-time events, and calls the API for data reads/writes. **No direct PostgreSQL access.**
## 2. Architecture
```
Browser ←─ SSE ──→ Web SM ──→ NATS (subscribe events)
←─ HTML ──→ ──→ API (HTTP, data reads/writes)
```
**Key principle:** Full Datastar hypermedia. The server renders all HTML. Client-side JS is minimal — only what Datastar requires plus small helpers for image paste and textarea auto-resize.
## 3. Page Layout (Discord-Style)
```
┌─────┬───────────────┬─────────────────────────────────────────┬──────────────┐
│ │ │ # general ☰ │ │
│ C │ CATEGORIES │ ┌── channel topic text ──────────┐ │ THREAD │
│ O │ │ │ │ │ PANEL │
│ M │ ▼ General │ │ alice 10:30 AM │ │ (when open) │
│ M │ # general │ │ hello everyone! │ │ │
│ U │ # random │ │ │ │ Thread in │
│ N │ │ │ bob 10:31 AM │ │ # general │
│ I │ ▼ Dev │ │ hey! check this out │ │ │
│ T │ # backend │ │ [image preview] │ │ alice: │
│ Y │ # frontend │ │ │ │ hello! │
│ │ │ │ ── New Messages ── │ │ │
│ I │ ───────── │ │ │ │ bob: │
│ C │ DIRECT MSGS │ │ carol 10:45 AM │ │ reply here │
│ O │ alice │ │ @bob nice work! 👍 │ │ │
│ N │ bob ● │ │ │ │ │
│ S │ group (3) │ │ ── Load older messages ── │ │ │
│ │ │ │ │ │ │
│ + │ │ ├────────────────────────────────┤ │ │
│ │ 🔍 Search │ │ @mention autocomplete ↕ │ │ │
│ │ │ │ [message input area ] 📎│ │ │
│ │ │ │ bob is typing... │ │ │
│ │ │ └────────────────────────────────┘ │ │
└─────┴───────────────┴─────────────────────────────────────────┴──────────────┘
```
### 3.1 Layout Regions
| Region | ID | Description |
|--------|----|-------------|
| Community Strip | `#community-strip` | Vertical icon strip (far left). Community avatars/initials. Click to switch. `+` button to create/join. |
| Sidebar | `#sidebar` | Channel list with collapsible categories. DM section below separator. Search at bottom. |
| Channel Header | `#channel-header` | Channel name, topic, settings icon. |
| Message List | `#message-list` | Paginated message display. "Load older" button at top. New message divider. |
| Message Input | `#message-input` | Auto-expanding textarea, @mention autocomplete, image paste, typing indicator display. |
| Thread Panel | `#thread-panel` | Slide-in panel on right when viewing a thread. Shows root message + replies. |
| Member List | `#member-list` | Optional right panel showing channel members + presence. Toggle via header icon. |
### 3.2 Community Strip
```
┌─────┐
│ MT │ ← "My Team" (initials, or community avatar)
├─────┤
│ OS │ ← "Open Source"
├─────┤
│ │
│ ... │
├─────┤
│ DM │ ← DMs section (always present, not community-scoped)
├─────┤
│ + │ ← Create/join community
└─────┘
```
- Active community: highlighted border/background
- Unread indicator: dot badge on community icon
- Mention indicator: red badge with count
- DM icon always at top (or bottom — separate from communities)
- Each community remembers the last-viewed channel
### 3.3 Sidebar — Channel List
```
▼ GENERAL ← category (collapsible)
# general 3 ← unread count badge
# random
# announcements
▼ DEVELOPMENT
# backend ● ← mention indicator
# frontend
# devops
──────────────────────────── ← separator
DIRECT MESSAGES
alice ● ← online indicator (green dot)
bob
group chat (3) 2 ← unread count
```
- Categories collapsible (click header to toggle)
- Channels: bold name = has unread messages
- Unread count badge (number) for messages
- Red dot for unread @mentions
- DM section: shows online status dot, unread count
- Right-click channel → context menu (mute, leave, mark read)
### 3.4 Message Display
Each message renders as:
```
┌─ avatar ─┬─────────────────────────────────────────────┐
│ [img] │ alice 10:30 AM │
│ │ hello everyone! check out #backend │
│ │ │
│ │ [inline image preview ─────────────] │
│ │ │
│ │ 👍 3 ❤️ 1 │ + Add Reaction │ ⋮ More │
│ │ 💬 2 replies (last reply 5 min ago) │
└──────────┴──────────────────────────────────────────────┘
```
**Message elements:**
- Avatar (from user profile or OAuth)
- Username (display name, colored if admin/owner)
- Timestamp (relative for today, absolute for older)
- Body (rendered Discord markdown → HTML)
- Mentions: `@alice` highlighted, `@here` highlighted differently
- Channel links: `#backend` clickable
- Inline image preview (click for full size)
- Reaction bar: emoji + count, click to toggle your reaction, `+` to add new
- Thread indicator: reply count + last reply time, click to open thread panel
- Hover actions: reply (thread), react, more (edit, delete, copy link)
- Edited indicator: "(edited)" text after timestamp if `edited_at` is set
**Message grouping:** Consecutive messages from the same user within 5 minutes collapse (no repeated avatar/name).
### 3.5 Message Input Area
```
┌─────────────────────────────────────────────────────────┐
│ @bob ▼ ← autocomplete │
│ @bobby dropdown │
│ @bob_builder │
├─────────────────────────────────────────────────────────┤
│ │
│ [auto-expanding textarea ] 📎│
│ │
│ alice is typing... │
└─────────────────────────────────────────────────────────┘
```
- Auto-expanding textarea (grows with content, max ~10 lines then scroll)
- Enter to send, Shift+Enter for newline
- `@` triggers user mention autocomplete (search by username/display name)
- `#` triggers channel autocomplete
- `/` at start triggers slash command autocomplete
- `📎` button for image upload (opens file picker, images only)
- Ctrl+V / Cmd+V pastes clipboard images directly
- Typing indicator: "alice is typing..." / "alice and bob are typing..." / "several people are typing..."
### 3.6 Thread Panel
```
┌──────────────────────────────────┐
│ Thread in #general ✕ │
│─────────────────────────────────│
│ alice 10:30 AM │
│ hello everyone! │
│─────────── 3 replies ──────────│
│ bob 10:31 AM │
│ hey there! │
│ │
│ carol 10:32 AM │
│ hi all! │
│ │
│ [reply input ] │
│ ☐ Also send to #general │
└──────────────────────────────────┘
```
- Opens as a right panel (does not replace message list)
- Shows root message at top
- Thread replies below
- Reply input at bottom
- Optional "Also send to #general" checkbox (broadcasts reply to channel)
- Close button returns to normal view
### 3.7 Search Modal
```
┌──────────────────────────────────────────┐
│ 🔍 Search... │
│ ┌────────────────────────────────────┐ │
│ │ [search input ] │ │
│ │ Filter: ○ All ○ Messages │ │
│ │ ○ Channels ○ Users │ │
│ └────────────────────────────────────┘ │
│ │
│ Results: │
│ ┌────────────────────────────────────┐ │
│ │ #backend · alice · Feb 15 │ │
│ │ "check out the new **API** docs" │ │
│ ├────────────────────────────────────┤ │
│ │ #general · bob · Feb 14 │ │
│ │ "the API is looking good" │ │
│ └────────────────────────────────────┘ │
│ Load more results │
└──────────────────────────────────────────┘
```
- Triggered by Ctrl+K or clicking search in sidebar
- Search input with type filter tabs
- Results show context (channel, author, date) with highlighted matches
- Click result → navigate to message in channel (scroll to it)
## 4. Datastar SSE Integration
### 4.1 How It Works
Datastar uses SSE to push HTML fragment updates from server to client. The server decides what changes and sends targeted DOM updates.
**Connection flow:**
1. Browser loads initial full page (server-rendered Hiccup)
2. Datastar opens SSE connection to `/sse/events`
3. Server subscribes to relevant NATS subjects for the user
4. When events arrive (new message, reaction, typing, etc.), server:
a. Renders the HTML fragment for the change
b. Sends it over SSE with a Datastar merge directive
c. Browser updates the DOM in-place
**SSE endpoint:** `GET /sse/events`
- Params: `?community_id=<uuid>` (which community is active)
- Server holds connection open, pushes fragments
### 4.2 Datastar Fragment Types
| Event | Fragment Target | Behavior |
|-------|----------------|----------|
| New message | `#message-list` | Append message HTML at bottom |
| Message edited | `#msg-{id}` | Replace message content |
| Message deleted | `#msg-{id}` | Remove element |
| Reaction added/removed | `#reactions-{msg-id}` | Replace reaction bar |
| Typing start | `#typing-indicator` | Update typing text |
| Typing stop | `#typing-indicator` | Update typing text |
| User online | `#member-{user-id}` | Update presence dot |
| User offline | `#member-{user-id}` | Update presence dot |
| Channel created | `#channel-list` | Append channel to sidebar |
| Unread count update | `#unread-{channel-id}` | Update badge number |
| Notification | `#notification-badge` | Update notification count |
| Thread reply | `#thread-{parent-id}` | Update reply count / append in thread panel |
### 4.3 SSE Reconnection
If SSE connection drops:
1. Datastar auto-reconnects (built-in)
2. Server uses NATS JetStream to replay missed events since last event ID
3. `Last-Event-ID` header used to resume from correct position
## 5. Client → Server Signals
All user actions are HTTP POSTs (Datastar form submissions):
| Action | Endpoint | Payload |
|--------|----------|---------|
| Send message | `POST /web/messages` | `{channel_id, body_md, parent_id?}` |
| Edit message | `POST /web/messages/:id/edit` | `{body_md}` |
| Delete message | `POST /web/messages/:id/delete` | — |
| Add reaction | `POST /web/reactions` | `{message_id, emoji}` |
| Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` |
| Switch channel | `POST /web/navigate` | `{channel_id}` |
| Switch community | `POST /web/navigate` | `{community_id}` |
| Mark channel read | `POST /web/read` | `{channel_id, message_id}` |
| Upload image | `POST /web/upload` | Multipart (image file) |
| Typing indicator | `POST /web/typing` | `{channel_id}` |
| Search | `POST /web/search` | `{query, type?, community_id?}` |
| Slash command | `POST /web/command` | `{command, channel_id, community_id}` |
| Create community | `POST /web/communities` | `{name, slug}` |
| Create channel | `POST /web/channels` | `{name, type, visibility, category_id?}` |
All of these proxy to the API internally and return Datastar fragment responses.
## 6. Pages & Routes (Server-Rendered)
| Route | Description |
|-------|-------------|
| `GET /` | Redirect to last community or setup wizard |
| `GET /app` | Main chat application (full page render) |
| `GET /app/channel/:id` | Direct link to a channel |
| `GET /app/dm/:id` | Direct link to a DM |
| `GET /setup` | Community creation wizard (first-time only) |
### 6.1 Setup Wizard Page
```
┌──────────────────────────────────────┐
│ │
│ Welcome to ajet chat! │
│ │
│ Create your community │
│ │
│ Name: [My Team ] │
│ Slug: [my-team ] │
│ chat.example.com/my-team │
│ │
│ [ Create Community ] │
│ │
└──────────────────────────────────────┘
```
- Shown after first-ever OAuth login
- Slug auto-generated from name (lowercase, hyphenated)
- On submit: creates community + #general, redirects to /app
## 7. Connection Tracking
Web SM maintains per-user connection state:
```clojure
{user-id {:sse-connection <http-kit-channel>
:active-community uuid
:active-channel uuid
:nats-subs [sub-handles...]
:last-seen instant}}
```
- On SSE connect: subscribe to user's communities on NATS, track connection
- On SSE disconnect: unsubscribe from NATS, clean up
- On community switch: unsubscribe old community subjects, subscribe new ones
- Multiple tabs: each tab is a separate SSE connection with independent state
## 8. Unread Tracking
**How unread counts are calculated:**
1. `channel_members.last_read_message_id` — stored in API DB
2. When user views a channel: Web SM calls `POST /api/channels/:id/read` with the latest message ID
3. Unread count = messages in channel with `id > last_read_message_id`
4. Mention count = mentions targeting user in those unread messages
5. On SSE events: Web SM pushes updated badge fragments for affected channels
**Sidebar rendering:**
- Bold channel name if unread count > 0
- Number badge for unread message count
- Red dot if unread mentions exist
- Community icon badge: sum of unread mentions across all channels in that community
## 9. Test Cases
### 9.1 Page Rendering
| ID | Test | Description |
|----|------|-------------|
| WEB-T1 | Initial page load | GET /app returns full HTML with sidebar, message list, input area |
| WEB-T2 | Channel list renders | Sidebar shows categories + channels for active community |
| WEB-T3 | DM list renders | DM section shows user's DM channels |
| WEB-T4 | Community strip renders | Icon strip shows all user's communities |
| WEB-T5 | Message list renders | Channel messages displayed with correct formatting |
| WEB-T6 | Markdown rendering | Discord markdown renders correctly as HTML |
| WEB-T7 | Mention rendering | `@<user:uuid>` renders as `@displayname` with highlight |
| WEB-T8 | Image inline preview | Image attachments render as inline previews |
| 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
| ID | Test | Description |
|----|------|-------------|
| WEB-T11 | SSE connection established | Browser opens SSE, server subscribes to NATS |
| WEB-T12 | New message appears | Message from another user appears in message list without refresh |
| WEB-T13 | Message edit updates | Edited message content updates in-place |
| WEB-T14 | Message delete removes | Deleted message disappears from list |
| WEB-T15 | Reaction update | Adding/removing reaction updates reaction bar in real-time |
| WEB-T16 | Typing indicator shows | Other user typing shows indicator below input |
| WEB-T17 | Typing indicator clears | Indicator disappears after 15 seconds of no typing |
| WEB-T18 | Presence update | User going online/offline updates sidebar dot |
| WEB-T19 | New channel appears | Channel created by admin appears in sidebar |
| WEB-T20 | Unread badge updates | New message in other channel updates unread count badge |
| WEB-T21 | SSE reconnect | After connection drop, reconnects and catches up on missed events |
| 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
| ID | Test | Description |
|----|------|-------------|
| WEB-T24 | Send message | Typing and pressing Enter sends message, appears in list |
| WEB-T25 | Shift+Enter newline | Shift+Enter adds newline, does not send |
| WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention |
| WEB-T27 | #channel autocomplete | Typing # shows channel dropdown |
| WEB-T28 | /slash command | Typing / shows command autocomplete |
| WEB-T29 | Edit message (hover) | Hover → edit icon → inline edit mode → save |
| WEB-T30 | Edit after 1 hour | Edit button not shown for messages > 1 hour old |
| WEB-T31 | Delete message | Hover → delete icon → confirmation → message removed |
| WEB-T32 | Add reaction | Click + on reaction bar → emoji picker → reaction added |
| WEB-T33 | Toggle reaction | Click existing reaction emoji to toggle on/off |
| WEB-T34 | Image paste | Ctrl+V with clipboard image → upload → preview in input → send |
| WEB-T35 | Image upload button | Click 📎 → file picker → select image → upload → send |
| WEB-T36 | Open thread | Click thread indicator → thread panel opens on right |
| WEB-T37 | Reply in thread | Type in thread input → reply appears in thread |
| WEB-T38 | Search | Ctrl+K → search modal → type query → results shown → click to navigate |
| WEB-T39 | Create channel | Admin clicks + → form → submits → channel appears in sidebar |
| WEB-T40 | Join public channel | Click channel → join prompt → joined → messages load |
| WEB-T41 | Leave channel | Right-click → leave → channel removed from sidebar |
| WEB-T42 | Collapse category | Click category header → channels hidden → click again → shown |
| WEB-T43 | Mark channel read | Opening channel marks it as read, badge clears |
| 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
| ID | Test | Description |
|----|------|-------------|
| WEB-T46 | User profile popover | Click username → popover with avatar, name, status, role |
| WEB-T47 | Set status | Click own avatar → status input → save |
| WEB-T48 | Set nickname | In community settings → nickname field → save |
### 9.5 Error Handling
| ID | Test | Description |
|----|------|-------------|
| WEB-T49 | API unreachable | Shows error banner, retries |
| WEB-T50 | Message send fails | Error shown inline below input, message not lost |
| WEB-T51 | Upload too large | Error shown, file not uploaded |
| WEB-T52 | Rate limited | Error shown with retry countdown |
### 9.6 Responsive / Layout
| ID | Test | Description |
|----|------|-------------|
| WEB-T53 | Desktop layout | Full 3-column layout renders correctly |
| WEB-T54 | Thread panel coexists | Thread panel + message list visible simultaneously |
| WEB-T55 | Long messages wrap | Long messages wrap correctly, no horizontal scroll |
| WEB-T56 | Code block rendering | Fenced code blocks render with syntax highlighting |
| WEB-T57 | Spoiler tags | `||spoiler||` renders hidden, click to reveal |