Files
ajet-chat/docs/prd/shared.md
2026-02-17 18:54:08 -05:00

430 lines
21 KiB
Markdown

# 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 | Bidirectional cursor-based pagination (`?before=<uuid>&after=<uuid>&limit=N`) | P0 |
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
| AC-12 | Request timeout (configurable, default 10s) | P0 |
| AC-13 | Structured error responses (`ex-info` with status + body) | P0 |
| AC-14 | File upload (multipart) for image attachments | P0 |
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| AC-T1 | Build headers with token | Unit | `build-headers` includes Authorization + trace ID |
| AC-T2 | Build headers without token | Unit | No Authorization header when token nil |
| AC-T3 | JSON encode/decode roundtrip | Unit | Clojure map survives JSON serialization |
| AC-T4 | 4xx response throws ex-info | Unit | `check-response!` throws with status + body |
| AC-T5 | 5xx response throws ex-info | Unit | Includes status code and response body |
| AC-T6 | get-channels returns list | Integration | Returns vector of channel maps |
| AC-T7 | send-message creates message | Integration | POST returns created message with ID |
| AC-T8 | edit-message within window | Integration | PUT succeeds within 1-hour window |
| AC-T9 | edit-message after window | Integration | PUT returns 403 after 1-hour window |
| AC-T10 | delete-message own | Integration | DELETE succeeds for message author |
| AC-T11 | delete-message other (non-admin) | Integration | DELETE returns 403 |
| AC-T12 | cursor pagination | Integration | `?after=uuid&limit=20` returns next page |
| AC-T13 | search messages | Integration | Returns matching messages across accessible channels |
| AC-T14 | retry on 503 | Unit | Retries up to 3 times with backoff |
| AC-T15 | timeout handling | Unit | Throws after configured timeout |
| AC-T16 | image upload multipart | Integration | File uploaded, attachment record created |
---
### 2.4 Schemas & Validation (`ajet.chat.shared.schema`)
**Purpose:** Data shape definitions and validation for all entities.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| SC-1 | Define specs/schemas for all DB entities | P0 |
| SC-2 | Request validation for API endpoints | P0 |
| SC-3 | Event payload validation | P1 |
| SC-4 | Use `clojure.spec.alpha` or Malli (TBD) | P0 |
**Entities to define:**
```
community: {:id uuid, :name string, :slug string, :created-at inst}
user: {:id uuid, :username string, :display-name string, :email string, :avatar-url string?, :status-text string?, :created-at inst}
oauth-account: {:id uuid, :user-id uuid, :provider string, :provider-user-id string}
session: {:id uuid, :user-id uuid, :token-hash string, :expires-at inst}
api-user: {:id uuid, :name string, :community-id uuid, :scopes [string]}
api-token: {:id uuid, :api-user-id uuid, :token-hash string, :scopes [string], :expires-at inst}
channel: {:id uuid, :community-id uuid?, :name string, :type enum, :visibility enum, :topic string?, :category-id uuid?}
channel-category:{:id uuid, :community-id uuid, :name string, :position int}
community-member:{:community-id uuid, :user-id uuid, :role enum, :nickname string?, :avatar-url string?}
channel-member: {:channel-id uuid, :user-id uuid, :joined-at inst}
message: {:id uuid, :channel-id uuid, :user-id uuid, :parent-id uuid?, :body-md string, :created-at inst, :edited-at inst?}
attachment: {:id uuid, :message-id uuid, :filename string, :content-type string, :size-bytes int, :storage-key string}
reaction: {:message-id uuid, :user-id uuid, :emoji string}
webhook: {:id uuid, :community-id uuid, :channel-id uuid, :name string, :avatar-url string?, :token-hash string}
mention: {:id uuid, :message-id uuid, :target-type enum, :target-id uuid?}
notification: {:id uuid, :user-id uuid, :type enum, :source-id uuid, :read boolean}
invite: {:id uuid, :community-id uuid, :created-by uuid, :code string, :max-uses int?, :uses int, :expires-at inst?}
oauth-provider: {:id uuid, :provider-type enum, :name string, :client-id string, :client-secret-encrypted string, :base-url string?, :issuer-url string?, :enabled boolean, :created-at inst, :updated-at inst}
system-setting: {:key string, :value any, :updated-at inst}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| SC-T1 | Valid community passes validation | Unit | All required fields present and correct types |
| SC-T2 | Missing required field fails | Unit | Omitting `:name` from community fails validation |
| SC-T3 | Invalid UUID format fails | Unit | String "not-a-uuid" in UUID field fails |
| SC-T4 | Enum validation | Unit | Channel `:type` must be one of `#{:text :voice :dm :group-dm}` |
| SC-T5 | Optional fields accepted when nil | Unit | `:topic nil` passes for channel |
| SC-T6 | Message body not empty | Unit | Empty string body fails validation |
| SC-T7 | Slug format validation | Unit | Community slug must match `[a-z0-9-]+` |
| SC-T8 | Mention storage format | Unit | `@<user:uuid>` parses correctly to `{:target-type :user :target-id uuid}` |
| SC-T9 | Event envelope validation | Unit | Event with valid type + payload passes |
---
### 2.5 Mention Parser (`ajet.chat.shared.mentions`)
**Purpose:** Parse and render mention/channel-link syntax in message bodies.
**Storage format:** Raw markdown with embedded references:
- `@<user:550e8400-e29b-41d4-a716-446655440000>` — user mention
- `@<here>`@here mention (notify all online in channel)
- `#<channel:550e8400-e29b-41d4-a716-446655440000>` — channel link
**Rendered output:** (resolved at display time by SMs)
- `@someUser` — rendered with user's display name
- `@here` — rendered as-is
- `#general` — rendered with channel name
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| MP-1 | Parse `@<user:uuid>` from message body, extract UUIDs | P0 |
| MP-2 | Parse `@<here>` from message body | P0 |
| MP-3 | Parse `#<channel:uuid>` from message body | P0 |
| MP-4 | Return list of `{:type :target-id}` for mention record creation | P0 |
| MP-5 | Render mentions by replacing references with display names | P0 |
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| MP-T1 | Parse single user mention | Unit | `"hello @<user:abc-123>"``[{:type :user :id "abc-123"}]` |
| MP-T2 | Parse multiple mentions | Unit | Two user mentions in one message → both extracted |
| MP-T3 | Parse @here | Unit | `"@<here> look at this"``[{:type :here}]` |
| MP-T4 | Parse channel link | Unit | `"see #<channel:def-456>"``[{:type :channel :id "def-456"}]` |
| MP-T5 | Parse mixed mentions | Unit | User + here + channel in one message → all three extracted |
| MP-T6 | No mentions returns empty | Unit | `"just a normal message"``[]` |
| MP-T7 | Mention inside code block ignored | Unit | `` `@<user:abc>` `` → `[]` (code blocks are not parsed) |
| MP-T8 | Render user mention | Unit | Replace `@<user:abc>` with `@alice` given lookup map |
| MP-T9 | Render channel link | Unit | Replace `#<channel:def>` with `#general` given lookup map |
| MP-T10 | Render unknown user | Unit | `@<user:unknown>` renders as `@unknown-user` |
---
### 2.6 Markdown Processor (`ajet.chat.shared.markdown`)
**Purpose:** Parse and render Discord-flavor markdown to HTML (for web) and ANSI (for TUI).
**Supported syntax:**
- **Bold:** `**text**`
- *Italic:* `*text*` or `_text_`
- ~~Strikethrough:~~ `~~text~~`
- __Underline:__ `__text__` (Discord extension)
- `Inline code`: `` `code` ``
- Code blocks: ` ```lang\ncode\n``` ` with syntax highlighting
- Spoilers: `||hidden text||`
- Block quotes: `> text`
- Links: `[text](url)` — auto-link bare URLs
- Emoji shortcodes: `:smile:` → rendered emoji
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| MD-T1 | Bold rendering | Unit | `**bold**``<strong>bold</strong>` |
| MD-T2 | Italic rendering | Unit | `*italic*``<em>italic</em>` |
| MD-T3 | Strikethrough | Unit | `~~strike~~``<del>strike</del>` |
| MD-T4 | Underline | Unit | `__underline__``<u>underline</u>` |
| MD-T5 | Inline code | Unit | `` `code` `` → `<code>code</code>` |
| MD-T6 | Fenced code block | Unit | Triple backtick block → `<pre><code>` with language class |
| MD-T7 | Spoiler | Unit | `\|\|spoiler\|\|` → `<span class="spoiler">spoiler</span>` |
| MD-T8 | Block quote | Unit | `> quote` → `<blockquote>quote</blockquote>` |
| MD-T9 | Auto-link URL | Unit | Bare `https://example.com` → clickable link |
| MD-T10 | Nested formatting | Unit | `**bold and *italic***` renders correctly |
| MD-T11 | XSS prevention | Unit | `<script>alert(1)</script>` is escaped, not executed |
| MD-T12 | Emoji shortcode | Unit | `:smile:` → Unicode emoji character |
| MD-T13 | ANSI rendering for TUI | Unit | `**bold**` → ANSI bold escape code |
| MD-T14 | Code block in ANSI | Unit | Code block rendered with box-drawing characters |
| MD-T15 | Mentions not processed | Unit | Markdown processor does not handle `@<user:>` (separate concern) |
---
### 2.7 Config Loader (`ajet.chat.shared.config`)
**Purpose:** Load, validate, and merge EDN configuration from files and environment variables. All modules use this for startup configuration.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| CFG-1 | Load config from EDN file path (default: `config.edn` in classpath) | P0 |
| CFG-2 | Deep-merge module-specific config over shared defaults | P0 |
| CFG-3 | Override any config key via environment variables (`AJET_DB_HOST` → `{:db {:host ...}}`) | P0 |
| CFG-4 | Validate required keys on load, throw clear error on missing/invalid config | P0 |
| CFG-5 | Support profiles (`:dev`, `:test`, `:prod`) — merge profile-specific overrides | P1 |
| CFG-6 | Secrets from env vars only — never log or serialize secret values | P0 |
**Env var mapping convention:**
```
AJET_DB_HOST → {:db {:host "..."}}
AJET_DB_PORT → {:db {:port 5432}}
AJET_DB_PASSWORD → {:db {:password "..."}}
AJET_NATS_URL → {:nats {:url "..."}}
AJET_OAUTH_GITHUB_CLIENT_ID → {:oauth {:github {:client-id "..."}}}
```
Underscores map to nested keys. Numeric strings auto-coerce to integers. `"true"`/`"false"` coerce to booleans.
**Shared defaults:**
```clojure
{:db {:host "localhost" :port 5432 :dbname "ajet_chat" :user "ajet" :pool-size 10}
:nats {:url "nats://localhost:4222"}
:minio {:endpoint "http://localhost:9000" :access-key "minioadmin" :secret-key "minioadmin" :bucket "ajet-chat"}}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| CFG-T1 | Load valid EDN config | Unit | `load-config` parses EDN file and returns map |
| CFG-T2 | Missing config file | Unit | Throws with clear error message |
| CFG-T3 | Env var override | Unit | `AJET_DB_HOST=remote` overrides `{:db {:host "localhost"}}` |
| CFG-T4 | Env var numeric coercion | Unit | `AJET_DB_PORT=5433` becomes integer 5433 |
| CFG-T5 | Deep merge module config | Unit | Module config merges over defaults without clobbering sibling keys |
| CFG-T6 | Missing required key | Unit | Missing `:db :host` throws validation error |
| CFG-T7 | Profile merge | Unit | `:test` profile overrides `{:db {:dbname "ajet_chat_test"}}` |
| CFG-T8 | Secrets not logged | Unit | Config with `:password` redacts value in string representation |
---
### 2.8 Logging (`ajet.chat.shared.logging`)
**Purpose:** Structured logging with trace ID propagation across all services.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| LOG-1 | Use `clojure.tools.logging` with Logback backend | P0 |
| LOG-2 | Structured JSON log format in production, human-readable in dev | P0 |
| LOG-3 | Bind `trace-id` to MDC (Mapped Diagnostic Context) per request | P0 |
| LOG-4 | Log request/response summary for every HTTP request (method, path, status, duration) | P0 |
| LOG-5 | Log level configurable per namespace via config | P1 |
| LOG-6 | Redact sensitive fields (passwords, tokens) in log output | P0 |
**MDC fields per request:**
```
trace-id: UUID from X-Trace-Id header
user-id: UUID from X-User-Id header (if authenticated)
method: HTTP method
path: Request path
```
**Log format (production):**
```json
{"timestamp":"2026-02-17T10:30:00Z","level":"INFO","logger":"ajet.chat.api.routes","trace-id":"uuid","user-id":"uuid","msg":"POST /api/channels/uuid/messages 201 (45ms)"}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| LOG-T1 | Request logging middleware | Unit | Request/response logged with method, path, status, duration |
| LOG-T2 | Trace ID in MDC | Unit | Log entries include trace-id from request header |
| LOG-T3 | Sensitive field redaction | Unit | Password and token values replaced with `[REDACTED]` |
| LOG-T4 | JSON format in prod | Unit | Log output parses as valid JSON in `:prod` profile |
| LOG-T5 | Human-readable in dev | Unit | Log output is plain text with colors in `:dev` profile |
---
### 2.9 File Storage Client (`ajet.chat.shared.storage`)
**Purpose:** S3-compatible client for MinIO/AWS S3 file operations.
**Requirements:**
| ID | Requirement | Priority |
|----|-------------|----------|
| FS-1 | `upload!` — upload bytes/stream to storage with key | P0 |
| FS-2 | `download` — get file bytes/stream by key | P0 |
| FS-3 | `delete!` — remove file by key | P0 |
| FS-4 | `presigned-url` — generate time-limited download URL | P1 |
| FS-5 | Create bucket on startup if it doesn't exist | P0 |
| FS-6 | Validate content-type (images only: JPEG, PNG, GIF, WebP) | P0 |
| FS-7 | Validate file size (max 10MB) | P0 |
| FS-8 | Storage key format: `attachments/{uuid}/{filename}` | P0 |
**Config shape:**
```clojure
{:minio {:endpoint "http://localhost:9000"
:access-key "minioadmin"
:secret-key "minioadmin"
:bucket "ajet-chat"}}
```
**Test Cases:**
| ID | Test | Type | Description |
|----|------|------|-------------|
| FS-T1 | Upload file | Integration | `upload!` stores file, retrievable by key |
| FS-T2 | Download file | Integration | `download` returns same bytes as uploaded |
| FS-T3 | Delete file | Integration | `delete!` removes file, subsequent download returns nil |
| FS-T4 | Presigned URL | Integration | Generated URL is accessible and expires |
| FS-T5 | Invalid content-type | Unit | Non-image content-type throws validation error |
| FS-T6 | Oversized file | Unit | File > 10MB throws validation error |
| FS-T7 | Bucket auto-creation | Integration | On startup, creates bucket if missing |
| FS-T8 | Upload with storage key format | Unit | Key matches `attachments/{uuid}/{filename}` pattern |