# 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=&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?} 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 | `@` 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) | --- ### 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 |