21 KiB
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:
{: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:
{:type :message/created ;; keyword
:id "uuid" ;; event ID for dedup
:ts "2026-02-17T..." ;; ISO 8601
:payload {:message-id "uuid"
:channel-id "uuid"
:user-id "uuid"
:body-md "hello"}}
Event types:
:message/created, :message/edited, :message/deleted
:reaction/added, :reaction/removed
:channel/created, :channel/updated, :channel/deleted
:member/joined, :member/left, :member/kicked, :member/banned
:typing/start, :typing/stop
:presence/online, :presence/offline
:notification/new
Test Cases:
| ID | Test | Type | Description |
|---|---|---|---|
| EB-T1 | Connect to NATS | Integration | connect! returns active connection |
| EB-T2 | Connect with bad URL | Unit | Throws clear error |
| EB-T3 | Publish and subscribe | Integration | Published event is received by subscriber |
| EB-T4 | Wildcard subscribe | Integration | chat.events.* receives events for any community |
| EB-T5 | Unsubscribe stops delivery | Integration | After unsubscribe!, no more events received |
| EB-T6 | Multiple subscribers | Integration | Two subscribers on same subject both receive event |
| EB-T7 | JSON roundtrip | Unit | EDN map → JSON → EDN map preserves all fields |
| EB-T8 | Event envelope validation | Unit | Missing :type or :payload throws |
| EB-T9 | JetStream replay | Integration | Durable consumer replays missed events on reconnect |
| EB-T10 | Auto-reconnect | Integration | Subscriber resumes after NATS restart |
| EB-T11 | Close connection | Integration | close! disconnects cleanly, subsequent publish throws |
2.3 API Client SDK (ajet.chat.shared.api-client)
Purpose: HTTP client for internal (SM → API) and external (CLI → Auth GW) communication.
Status: Substantially implemented (219 lines). Needs expansion.
Requirements:
| ID | Requirement | Priority |
|---|---|---|
| AC-1 | Context-based design: {:base-url :auth-token :trace-id} |
P0 |
| AC-2 | All CRUD for: channels, messages, reactions, DMs, users, notifications | P0 |
| AC-3 | Heartbeat endpoint | P0 |
| AC-4 | Search endpoint (messages, channels, users) | P0 |
| AC-5 | Invite management (create link, direct invite, list, revoke) | P0 |
| AC-6 | Slash command dispatch | P0 |
| AC-7 | Community CRUD (create, update, list, get) | P0 |
| AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 |
| AC-9 | Webhook management (create, delete, list) | P1 |
| AC-10 | Cursor-based pagination support (?after=<uuid>&limit=N) |
P0 |
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
| AC-12 | Request timeout (configurable, default 10s) | P0 |
| AC-13 | Structured error responses (ex-info with status + body) |
P0 |
| AC-14 | File upload (multipart) for image attachments | P0 |
Test Cases:
| ID | Test | Type | Description |
|---|---|---|---|
| AC-T1 | Build headers with token | Unit | build-headers includes Authorization + trace ID |
| AC-T2 | Build headers without token | Unit | No Authorization header when token nil |
| AC-T3 | JSON encode/decode roundtrip | Unit | Clojure map survives JSON serialization |
| AC-T4 | 4xx response throws ex-info | Unit | check-response! throws with status + body |
| AC-T5 | 5xx response throws ex-info | Unit | Includes status code and response body |
| AC-T6 | get-channels returns list | Integration | Returns vector of channel maps |
| AC-T7 | send-message creates message | Integration | POST returns created message with ID |
| AC-T8 | edit-message within window | Integration | PUT succeeds within 1-hour window |
| AC-T9 | edit-message after window | Integration | PUT returns 403 after 1-hour window |
| AC-T10 | delete-message own | Integration | DELETE succeeds for message author |
| AC-T11 | delete-message other (non-admin) | Integration | DELETE returns 403 |
| AC-T12 | cursor pagination | Integration | ?after=uuid&limit=20 returns next page |
| AC-T13 | search messages | Integration | Returns matching messages across accessible channels |
| AC-T14 | retry on 503 | Unit | Retries up to 3 times with backoff |
| AC-T15 | timeout handling | Unit | Throws after configured timeout |
| AC-T16 | image upload multipart | Integration | File uploaded, attachment record created |
2.4 Schemas & Validation (ajet.chat.shared.schema)
Purpose: Data shape definitions and validation for all entities.
Requirements:
| ID | Requirement | Priority |
|---|---|---|
| SC-1 | Define specs/schemas for all DB entities | P0 |
| SC-2 | Request validation for API endpoints | P0 |
| SC-3 | Event payload validation | P1 |
| SC-4 | Use clojure.spec.alpha or Malli (TBD) |
P0 |
Entities to define:
community: {:id uuid, :name string, :slug string, :created-at inst}
user: {:id uuid, :username string, :display-name string, :email string, :avatar-url string?, :status-text string?, :created-at inst}
oauth-account: {:id uuid, :user-id uuid, :provider string, :provider-user-id string}
session: {:id uuid, :user-id uuid, :token-hash string, :expires-at inst}
api-user: {:id uuid, :name string, :community-id uuid, :scopes [string]}
api-token: {:id uuid, :api-user-id uuid, :token-hash string, :scopes [string], :expires-at inst}
channel: {:id uuid, :community-id uuid?, :name string, :type enum, :visibility enum, :topic string?, :category-id uuid?}
channel-category:{:id uuid, :community-id uuid, :name string, :position int}
community-member:{:community-id uuid, :user-id uuid, :role enum, :nickname string?, :avatar-url string?}
channel-member: {:channel-id uuid, :user-id uuid, :joined-at inst}
message: {:id uuid, :channel-id uuid, :user-id uuid, :parent-id uuid?, :body-md string, :created-at inst, :edited-at inst?}
attachment: {:id uuid, :message-id uuid, :filename string, :content-type string, :size-bytes int, :storage-key string}
reaction: {:message-id uuid, :user-id uuid, :emoji string}
webhook: {:id uuid, :community-id uuid, :channel-id uuid, :name string, :avatar-url string?, :token-hash string}
mention: {:id uuid, :message-id uuid, :target-type enum, :target-id uuid?}
notification: {:id uuid, :user-id uuid, :type enum, :source-id uuid, :read boolean}
invite: {:id uuid, :community-id uuid, :created-by uuid, :code string, :max-uses int?, :uses int, :expires-at inst?}
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:
{: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):
{"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:
{: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 |