Files
ajet-chat/docs/prd/shared.md
2026-02-17 17:30:45 -05:00

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