diff --git a/docs/prd/README.md b/docs/prd/README.md new file mode 100644 index 0000000..90ebfec --- /dev/null +++ b/docs/prd/README.md @@ -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** — `@` / `@` / `#` 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 diff --git a/docs/prd/api.md b/docs/prd/api.md new file mode 100644 index 0000000..1936733 --- /dev/null +++ b/docs/prd/api.md @@ -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: ` — authenticated user + - `X-User-Role: ` — role in the community context + - `X-Community-Id: ` — current community (for community-scoped endpoints) + - `X-Trace-Id: ` — 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 FK→users, 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 FK→messages 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=&limit=50 +GET /api/channels/:id/messages?after=&limit=50 +Default limit: 50, max: 100 +``` + +**POST /api/channels/:id/messages** +``` +Request: {"body_md": "hello @ check #", "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=&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 +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 @", "channel_id": "uuid", "community_id": "uuid"} +``` + +**Commands v1:** + +| Command | Args | Permission | Description | +|---------|------|------------|-------------| +| `/help` | `[command]` | All | Show help or specific command help | +| `/topic` | `` | Admin+ | Set channel topic | +| `/nick` | `` | All | Set community nickname | +| `/invite` | `[max_uses] [expires_hours]` | Admin+ | Generate invite link | +| `/kick` | `@` | Admin+ | Kick user from community | +| `/ban` | `@` | Admin+ | Ban user from community | +| `/mute` | `@ ` | Admin+ | Mute user for duration | +| `/token create` | ` [scopes]` | Owner | Create API token | +| `/token revoke` | `` | Owner | Revoke API token | +| `/token list` | | Owner | List API tokens | +| `/webhook create` | ` [channel]` | Admin+ | Create incoming webhook | +| `/webhook delete` | `` | Admin+ | Delete webhook | +| `/webhook list` | | Admin+ | List webhooks | +| `/status` | `` | 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 diff --git a/docs/prd/auth-gw.md b/docs/prd/auth-gw.md new file mode 100644 index 0000000..0b89011 --- /dev/null +++ b/docs/prd/auth-gw.md @@ -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= +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 ` 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: +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 ` | + +### 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 │ +│ │ +└──────────────────────────────────────┘ +``` diff --git a/docs/prd/cli.md b/docs/prd/cli.md new file mode 100644 index 0000000..c3c9aa3 --- /dev/null +++ b/docs/prd/cli.md @@ -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 [options] [args] +``` + +### 3.2 Commands + +#### Authentication +```bash +ajet login # Interactive OAuth login (opens browser) +ajet login --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 # List channels in specific community +ajet channels --join # Join a public channel +ajet channels --leave # Leave a channel +``` + +#### Messages +```bash +ajet read # Read last 50 messages in channel +ajet read --limit 100 # Custom limit +ajet read --before # Older messages (pagination) +ajet read --thread # Read thread replies +ajet send # Send message +ajet send --stdin # Read message from stdin (piping) +ajet send --image # Send message with image +ajet edit # Edit a message +ajet delete # Delete a message +``` + +#### DMs +```bash +ajet dms # List DM channels +ajet dm # Send DM (creates if needed) +ajet dm --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 # Global search +ajet search --channel # Search in specific channel +ajet search --from # Search by author +ajet search --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 # Revoke an invite +``` + +#### Admin +```bash +ajet config # Show current config +ajet config set # Set config value +ajet config server # 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 # Open to specific community +ajet tui --channel # 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= +Cookie: ajet_session= +Accept: text/event-stream +Last-Event-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 | diff --git a/docs/prd/mobile.md b/docs/prd/mobile.md new file mode 100644 index 0000000..4306437 --- /dev/null +++ b/docs/prd/mobile.md @@ -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. diff --git a/docs/prd/shared.md b/docs/prd/shared.md new file mode 100644 index 0000000..75ab6e5 --- /dev/null +++ b/docs/prd/shared.md @@ -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=&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 | `@` 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 mention +- `@` — @here mention (notify all online in channel) +- `#` — 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 `@` from message body, extract UUIDs | P0 | +| MP-2 | Parse `@` from message body | P0 | +| MP-3 | Parse `#` 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 @"` → `[{:type :user :id "abc-123"}]` | +| MP-T2 | Parse multiple mentions | Unit | Two user mentions in one message → both extracted | +| MP-T3 | Parse @here | Unit | `"@ look at this"` → `[{:type :here}]` | +| MP-T4 | Parse channel link | Unit | `"see #"` → `[{: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 | `` `@` `` → `[]` (code blocks are not parsed) | +| MP-T8 | Render user mention | Unit | Replace `@` with `@alice` given lookup map | +| MP-T9 | Render channel link | Unit | Replace `#` with `#general` given lookup map | +| MP-T10 | Render unknown user | Unit | `@` 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**` → `bold` | +| MD-T2 | Italic rendering | Unit | `*italic*` → `italic` | +| MD-T3 | Strikethrough | Unit | `~~strike~~` → `strike` | +| MD-T4 | Underline | Unit | `__underline__` → `underline` | +| MD-T5 | Inline code | Unit | `` `code` `` → `code` | +| MD-T6 | Fenced code block | Unit | Triple backtick block → `
` with language class |
+| MD-T7 | Spoiler | Unit | `\|\|spoiler\|\|` → `spoiler` |
+| MD-T8 | Block quote | Unit | `> quote` → `
quote
` | +| 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 | `` 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 `@` (separate concern) | diff --git a/docs/prd/tui-sm.md b/docs/prd/tui-sm.md new file mode 100644 index 0000000..61dccef --- /dev/null +++ b/docs/prd/tui-sm.md @@ -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=` — active community (optional, can start with DMs) +- `last_event_id=` — for reconnection replay + +### 3.2 SSE Event Format + +``` +event: +id: +data: +``` + +### 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=&before=&limit=50` | +| Search | `GET /tui/search` | `?q=&type=` | +| Slash command | `POST /tui/command` | `{command, channel_id, community_id}` | + +All responses are JSON. + +## 5. Connection Tracking + +```clojure +{user-id {:sse-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 | diff --git a/docs/prd/web-sm.md b/docs/prd/web-sm.md new file mode 100644 index 0000000..f62b694 --- /dev/null +++ b/docs/prd/web-sm.md @@ -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=` (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 + :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 | `@` 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 |