项目文件夹

文件
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

1579 行
49 KiB
Markdown

# 03 - System Architecture
## 3.1 Architecture Overview
### High-Level Architecture
```mermaid
flowchart TB
subgraph Clients["Clients"]
C1[External Apps]
C2[Dashboard]
C3[n8n/Automation]
end
subgraph OpenWA["OpenWA Platform"]
subgraph API["API Layer"]
REST[REST API<br/>NestJS]
WS[WebSocket<br/>Real-time]
SWAGGER[Swagger<br/>Documentation]
end
subgraph Core["Core Services"]
SM[Session<br/>Manager]
MM[Message<br/>Manager]
WH[Webhook<br/>Manager]
QM[Queue<br/>Manager]
end
subgraph Engine["WhatsApp Engine (pluggable)"]
WW[whatsapp-web.js<br/>Puppeteer/Chromium]
BY[Baileys<br/>WebSocket/No browser]
end
subgraph Storage["Storage Layer"]
DB[(Database<br/>PostgreSQL/SQLite)]
REDIS[(Redis<br/>Cache/Queue)]
FS[File Storage<br/>Media Files]
end
end
subgraph External["External"]
WA[WhatsApp<br/>Servers]
WEBHOOK[Webhook<br/>Endpoints]
end
Clients --> API
API --> Core
Core --> Engine
Core --> Storage
Engine --> WA
Core --> WEBHOOK
```
### Component Interaction
```mermaid
sequenceDiagram
participant Client
participant API as REST API
participant SM as Session Manager
participant Engine as WA Engine
participant DB as Database
participant WA as WhatsApp
Client->>API: Create Session
API->>SM: createSession()
SM->>DB: Save session config
SM->>Engine: Initialize
Engine->>WA: Connect
WA-->>Engine: QR Code
Engine-->>SM: QR Ready
SM-->>API: QR Code data
API-->>Client: QR Code response
```
## 3.2 Pluggable Architecture Philosophy
OpenWA is designed with a **Pluggable Architecture** that allows infrastructure components to be swapped without changing application code. This enables flexible deployments ranging from minimal single-session bots to larger single-node, multi-session installs.
> **Note — single-instance:** the live WhatsApp engine layer is stateful and held in-process
> (an in-memory `Map` in `SessionService`). OpenWA currently runs as **one API instance per
> session-data volume**; horizontal scaling across multiple API replicas is a future design
> (not implemented). See [13 - Horizontal Scaling](13-horizontal-scaling.md).
### Design Philosophy
```mermaid
flowchart TB
subgraph Philosophy["Core Design Principles"]
P1[Program to Interfaces]
P2[Dependency Injection]
P3[Configuration-driven]
P4[Zero Code Changes]
end
subgraph Benefits["Benefits"]
B1[Scale Up/Down Freely]
B2[Test in Isolation]
B3[Swap Implementations]
B4[Environment-specific Config]
end
P1 --> B3
P2 --> B2
P3 --> B4
P4 --> B1
```
**Key Principles:**
| Principle | Description | Example |
|-----------|-------------|---------|
| **Program to Interfaces** | Core code depends on the `IWhatsAppEngine` abstraction, never on a concrete library | `IWhatsAppEngine` instead of `whatsapp-web.js` `Client` |
| **Dependency Injection** | Services are wired via NestJS DI (constructor injection of `EngineFactory`, `StorageService`, `CacheService`) | `constructor(private engineFactory: EngineFactory)` |
| **Configuration-driven** | Backend selection via environment variables | `STORAGE_TYPE=s3`, `ENGINE_TYPE=baileys` |
| **Zero Code Changes** | Switch backends without modifying application code | Change `.env`, restart |
### Adapter Categories
The WhatsApp engine is the one true plug-in interface (`IWhatsAppEngine`, with concrete
adapters resolved through the plugin loader). The other "pluggable" backends are not behind a
formal `I*Adapter` interface — they are single services that branch internally on a config value:
`StorageService` (`storageType` = `local` | `s3`), `CacheService` (Redis or fail-open no-op), and
the TypeORM `data` connection (`sqlite` | `postgres`).
```mermaid
flowchart LR
subgraph Core["Application Core"]
APP[Business Logic]
end
subgraph Boundaries["Swappable Boundaries"]
IE[IWhatsAppEngine<br/>interface + plugin loader]
SS[StorageService<br/>storageType branch]
CS[CacheService<br/>Redis / no-op]
DC[TypeORM 'data' conn<br/>sqlite / postgres]
end
subgraph Implementations["Backends"]
subgraph Engine
E1[whatsapp-web.js]
E2[Baileys]
end
subgraph Database
D1[SQLite]
D2[PostgreSQL]
end
subgraph Storage
S1[Local FS]
S2[S3 / MinIO]
end
subgraph Cache
C1[Redis]
C2[Disabled - no-op]
end
end
APP --> Boundaries
IE -.-> Engine
DC -.-> Database
SS -.-> Storage
CS -.-> Cache
```
### WhatsApp Identity Contract (engine-neutral ids)
WhatsApp addresses the same entity through several id dialects, and each engine speaks a different one:
whatsapp-web.js uses `<phone>@c.us`, while Baileys speaks the raw protocol forms `<phone>@s.whatsapp.net`
and `<lid>@lid` (a privacy id whose number is **not** a phone number). To keep application code, the
REST/webhook payloads, and plugins free of that, the **engine boundary is an anti-corruption layer**:
every WhatsApp id an engine emits in a neutral field (`from` / `to` / `chatId` / `author`, contact and
chat `id`) is reduced to one small **neutral dialect**:
| Neutral form | Meaning |
| --- | --- |
| `<phone>@c.us` | a user, by phone (the raw `@s.whatsapp.net` form folds into this) |
| `<id>@g.us` | a group |
| `<lid>@lid` | a user known **only** by privacy id - phone genuinely unknown (a first-class state) |
| `status@broadcast`, `<id>@newsletter`, `<id>@broadcast` | special channels |
Never `@s.whatsapp.net`, never a `:device` suffix. **Resolution rule:** prefer `@c.us` (resolve a lid
to its phone when the mapping is known), and fall back to `@lid` only when it can't be resolved - an
unresolved lid is never faked into a phone number.
The shared implementation lives in `src/engine/identity/wa-id.ts` (`parseWaId` / `toNeutralJid`); the
contract is documented on the `IWhatsAppEngine` interface.
> **Rollout status:** the contract is applied per-engine. It currently covers the **Baileys inbound
> read path** (message / revoked / reaction payloads). Outbound id de-normalization (neutral -> engine
> dialect on send) and contact/chat list ids are tracked follow-ups.
### Engine Lifecycle State Machine
A WhatsApp engine moves through the `EngineStatus` enum
(`engine/interfaces/whatsapp-engine.interface.ts`). The adapter reports the current value via
`getStatus()` and pushes transitions to the host through the `onStateChanged` callback supplied to
`initialize()`:
```mermaid
stateDiagram-v2
[*] --> Disconnected: Create instance
Disconnected --> Initializing: initialize(callbacks)
Initializing --> QrReady: QR emitted
QrReady --> Authenticating: QR scanned
Authenticating --> Ready: Auth success
Initializing --> Failed: Terminal error (onError)
Authenticating --> Failed: Credentials rejected
Ready --> Disconnected: disconnect() / dropped link
Ready --> Failed: Fatal error
Disconnected --> [*]
Failed --> [*]
```
```typescript
// engine/interfaces/whatsapp-engine.interface.ts
export enum EngineStatus {
DISCONNECTED = 'disconnected',
INITIALIZING = 'initializing',
QR_READY = 'qr_ready',
AUTHENTICATING = 'authenticating',
READY = 'ready',
FAILED = 'failed',
}
```
There is no generic `IAdapterLifecycle`/`AdapterState` abstraction — only the engine carries an
explicit status enum. Storage, cache, and the database connection have no separate lifecycle type;
they follow the standard NestJS provider lifecycle (`OnModuleInit` / `OnModuleDestroy`).
### Dependency Injection & Module Wiring
OpenWA does **not** use a dynamic `AdaptersModule` or string DI tokens. `AppModule`
(`src/app.module.ts`) imports concrete feature modules directly and configures two **named TypeORM
connections**:
- **`main`** — always SQLite (`./data/main.sqlite`); owns the auth (`api_keys`) and audit
(`audit_logs`) entities. Fixed boot config, not pluggable.
- **`data`** — the pluggable user-data connection: `sqlite` (default) or `postgres`, selected by
`DATABASE_TYPE`. Owns the session/webhook/message/template/engine entities.
The engine is provided by `EngineModule` as the `EngineFactory` **class** (a normal injectable, not a
string token). Storage and cache are provided as the `StorageService` and `CacheService` classes by
their respective modules.
```typescript
// src/app.module.ts (shape)
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate: validateEnv }),
// Auth + audit — always SQLite
TypeOrmModule.forRootAsync({ name: 'main', /* ... ./data/main.sqlite ... */ }),
// Pluggable user data — sqlite | postgres via DATABASE_TYPE
TypeOrmModule.forRootAsync({ name: 'data', /* ... */ }),
CacheModule, // provides CacheService
StorageModule, // provides StorageService
EngineModule, // provides EngineFactory
SessionModule, MessageModule, WebhookModule, /* ...other feature modules... */
],
})
export class AppModule {}
```
### Using the Backends in Services
Services receive the backends by **constructor injection of the concrete class** — there is no
`@Inject('…_ADAPTER')` token:
```typescript
@Injectable()
export class SomeService {
constructor(
private readonly storage: StorageService, // branches local vs s3 internally
private readonly cache: CacheService, // Redis when enabled, else a no-op
) {}
async saveMedia(filePath: string, data: Buffer) {
await this.storage.putFile(filePath, data); // path-safety guarded
await this.cache.setSessionStatus('id', 'READY'); // no-op if Redis disabled
}
}
```
### Runtime Configuration Flow
```mermaid
sequenceDiagram
participant Env as .env File
participant Config as ConfigService
participant Svc as StorageService
participant App as Application
Note over Env: STORAGE_TYPE=s3
Env->>Config: Load + validateEnv
Config->>Svc: storage.type = 's3'
Svc->>Svc: construct S3Client (forcePathStyle: true)
App->>Svc: putFile / getFile (unaware of backend)
```
### Backend Selection Matrix
| Environment | Database | Storage | Cache | Engine | Use Case |
|-------------|----------|---------|-------|--------|----------|
| **Development** | SQLite | Local | Disabled | whatsapp-web.js | Fast iteration, testing |
| **Testing** | SQLite | Local | Disabled | whatsapp-web.js | CI/CD, unit tests |
| **Staging** | PostgreSQL | Local | Redis | whatsapp-web.js | Pre-production validation |
| **Production (Small)** | SQLite | Local | Disabled | whatsapp-web.js | 1-3 sessions, VPS |
| **Production (Medium)** | PostgreSQL | Local | Redis | whatsapp-web.js | 5-10 sessions |
| **Production (Large)** | PostgreSQL | S3/MinIO | Redis | whatsapp-web.js | 10+ sessions, HA |
### Hot-Swap Considerations
> **Note:** Adapter hot-swap (changing adapter without restart) is **not supported** in v1.0. Changing adapter requires application restart.
Future considerations for hot-swap:
- Graceful connection draining
- State migration between adapters
- Zero-downtime switching
```mermaid
flowchart TD
A[Config Change Detected] --> B{Hot-swap Supported?}
B -->|v1.0: No| C[Log Warning]
C --> D[Require Restart]
B -->|Future: Yes| E[Drain Connections]
E --> F[Initialize New Adapter]
F --> G[Migrate State]
G --> H[Switch Traffic]
H --> I[Shutdown Old Adapter]
```
## 3.3 Layered Architecture
### Layered Architecture Pattern
```mermaid
flowchart TB
subgraph Presentation["Presentation Layer"]
direction LR
REST[REST Controllers]
WS[WebSocket Gateways]
SWAGGER[OpenAPI Docs]
end
subgraph Application["Application Layer"]
direction LR
SESS[Session Service]
MSG[Message Service]
WH[Webhook Service]
AUTH[Auth Service]
end
subgraph Domain["Domain Layer"]
direction LR
ENT[Entities]
REPO[Repository Interfaces]
EVT[Domain Events]
end
subgraph Infrastructure["Infrastructure Layer"]
direction LR
DB[Database]
CACHE[Redis]
ENGINE[WA Engine]
HTTP[HTTP Client]
end
Presentation --> Application
Application --> Domain
Application --> Infrastructure
Domain --> Infrastructure
```
## 3.4 Module Structure
### NestJS Module Organization
```
src/
├── main.ts # Application entry point
├── app.module.ts # Root module
├── common/ # Shared utilities
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ ├── interceptors/
│ ├── pipes/
│ └── utils/
├── config/ # Configuration
│ ├── config.module.ts
│ ├── config.service.ts
│ └── configuration.ts
├── modules/
│ ├── session/ # Session management
│ │ ├── session.module.ts
│ │ ├── session.controller.ts
│ │ ├── session.service.ts
│ │ ├── session.repository.ts
│ │ ├── dto/
│ │ └── entities/
│ │
│ ├── message/ # Message handling
│ │ ├── message.module.ts
│ │ ├── message.controller.ts
│ │ ├── message.service.ts
│ │ └── dto/
│ │
│ ├── webhook/ # Webhook management
│ │ ├── webhook.module.ts
│ │ ├── webhook.controller.ts
│ │ ├── webhook.service.ts
│ │ └── dto/
│ │
│ ├── contact/ # Contact management
│ │ ├── contact.module.ts
│ │ ├── contact.controller.ts
│ │ └── contact.service.ts
│ │
│ ├── group/ # Group management
│ │ ├── group.module.ts
│ │ ├── group.controller.ts
│ │ └── group.service.ts
│ │
│ ├── auth/ # Authentication
│ │ ├── auth.module.ts
│ │ ├── auth.guard.ts
│ │ └── api-key.strategy.ts
│ │
│ └── health/ # Health checks
│ ├── health.module.ts
│ └── health.controller.ts
├── engine/ # WhatsApp engine wrapper
│ ├── engine.module.ts
│ ├── engine.service.ts
│ ├── engine.factory.ts
│ └── interfaces/
├── queue/ # Job queue
│ ├── queue.module.ts
│ ├── processors/
│ └── jobs/
└── database/ # Database
├── database.module.ts
├── migrations/
└── seeds/
```
## 3.5 Core Components Design
### 3.5.1 Session Manager
```mermaid
classDiagram
class SessionManager {
-sessions: Map~string, Session~
-repository: SessionRepository
-engineFactory: EngineFactory
+createSession(config): Session
+getSession(id): Session
+deleteSession(id): void
+getAllSessions(): Session[]
+restoreSessions(): void
}
class Session {
+id: string
+name: string
+status: SessionStatus
+engine: WhatsAppEngine
+config: SessionConfig
+createdAt: Date
+start(): void
+stop(): void
+getQR(): string
}
class SessionStatus {
<<enumeration>>
CREATED
INITIALIZING
QR_READY
AUTHENTICATED
READY
DISCONNECTED
FAILED
}
class WhatsAppEngine {
<<interface>>
+initialize(callbacks): Promise~void~
+sendTextMessage(chatId, text): Promise~MessageResult~
+getStatus(): EngineStatus
+getContacts(): Promise~Contact[]~
+disconnect(): Promise~void~
}
SessionManager --> Session
Session --> SessionStatus
Session --> WhatsAppEngine
```
### 3.5.2 Message Flow
```mermaid
flowchart TB
subgraph Outbound["Outbound Message Flow"]
A1[API Request] --> V1[Validate]
V1 --> Q1[Queue Job]
Q1 --> P1[Process]
P1 --> E1[Engine Send]
E1 --> R1[Response]
end
subgraph Inbound["Inbound Message Flow"]
E2[Engine Event] --> P2[Process]
P2 --> S2[Store]
S2 --> W2[Webhook Queue]
W2 --> D2[Deliver]
end
```
### 3.5.3 Webhook System
```mermaid
classDiagram
class WebhookManager {
-webhooks: Webhook[]
-httpClient: HttpService
-queue: Queue
+registerWebhook(config): Webhook
+removeWebhook(id): void
+dispatch(event): void
-deliverWithRetry(webhook, payload): void
}
class Webhook {
+id: string
+url: string
+events: EventType[]
+secret: string
+active: boolean
+retryCount: number
+headers: Record
}
class WebhookPayload {
+event: EventType
+timestamp: Date
+sessionId: string
+data: any
+signature: string
}
class EventType {
<<enumeration>>
MESSAGE_RECEIVED
MESSAGE_SENT
MESSAGE_ACK
SESSION_STATUS
QR_CODE
}
WebhookManager --> Webhook
WebhookManager --> WebhookPayload
Webhook --> EventType
```
## 3.6 Data Flow Diagrams
### 3.6.1 Send Message Flow
```mermaid
flowchart LR
subgraph Request["1. Request"]
A[Client] -->|POST /messages| B[Controller]
end
subgraph Validation["2. Validation"]
B --> C{Valid?}
C -->|No| D[400 Error]
C -->|Yes| E[Service]
end
subgraph Processing["3. Processing"]
E --> F[Get Session]
F --> G{Session Ready?}
G -->|No| H[400 Error]
G -->|Yes| I[Queue Job]
end
subgraph Execution["4. Execution"]
I --> J[Worker]
J --> K[Engine]
K --> L[WhatsApp]
end
subgraph Response["5. Response"]
L --> M[Success]
M --> N[Store]
N --> O[Response]
end
```
### 3.6.2 Webhook Delivery Flow
```mermaid
flowchart TB
A[Event Triggered] --> B[Create Payload]
B --> C[Sign Payload]
C --> D[Queue Delivery Job]
D --> E[Worker Process]
E --> F{Deliver}
F -->|Success| G[Mark Delivered]
F -->|Failed| H{Retry < 3?}
H -->|Yes| I[Delay & Retry]
I --> E
H -->|No| J[Mark Failed]
J --> K[Log Error]
```
## 3.7 Technology Architecture
### 3.7.1 Runtime Environment
```mermaid
flowchart TB
subgraph Container["Docker Container"]
subgraph Node["Node.js Runtime"]
NEST[NestJS Application]
WW[whatsapp-web.js<br/>or Baileys]
end
subgraph Browser["Headless Browser (wwebjs only)"]
CHROME[Chromium]
end
Node -.->|ENGINE_TYPE=whatsapp-web.js| Browser
end
subgraph External["External Services"]
PG[(PostgreSQL)]
RD[(Redis)]
end
Container --> External
```
### 3.7.2 Deployment Architecture
```mermaid
flowchart TB
subgraph Production["Production Environment"]
LB[Load Balancer] --> I1[Instance 1]
LB --> I2[Instance 2]
LB --> I3[Instance N]
I1 --> DB[(PostgreSQL)]
I2 --> DB
I3 --> DB
I1 --> REDIS[(Redis)]
I2 --> REDIS
I3 --> REDIS
end
subgraph Storage["Shared Storage"]
S3[S3/MinIO<br/>Media Files]
end
I1 --> S3
I2 --> S3
I3 --> S3
```
## 3.8 API Architecture
### RESTful API Design
```mermaid
flowchart LR
subgraph Endpoints["API Endpoints"]
direction TB
S["/api/sessions"]
M["/api/sessions/:sessionId/messages"]
W["/api/sessions/:sessionId/webhooks"]
C["/api/sessions/:sessionId/contacts"]
G["/api/sessions/:sessionId/groups"]
H["/health"]
end
subgraph Methods["HTTP Methods"]
GET
POST
PUT
DELETE
end
subgraph Format["Response Format"]
JSON[JSON Response]
ERR[Error Format]
PAGE[Pagination]
end
```
### API Response Structure
Responses are the **raw handler payload** — there is no `{success, data, meta}` envelope.
A controller that returns an object sends exactly that object; a list endpoint returns a bare array.
Errors use the NestJS default shape.
```typescript
// Success Response — the resource itself
{
"id": "abc",
"name": "my-session",
"status": "READY"
}
// List Response — a bare array
[
{ "id": "abc", "name": "my-session", "status": "READY" },
{ "id": "def", "name": "other-session", "status": "DISCONNECTED" }
]
// Error Response — NestJS default shape
{
"statusCode": 404,
"message": "Session with id 'xxx' not found",
"error": "Not Found"
}
```
## 3.9 Security Architecture
```mermaid
flowchart TB
subgraph External["External Request"]
R[Request]
end
subgraph Security["Security Layers"]
R --> HTTPS[HTTPS/TLS]
HTTPS --> CORS[CORS Check]
CORS --> RATE[Rate Limiter]
RATE --> AUTH[API Key Auth]
AUTH --> VAL[Input Validation]
VAL --> APP[Application]
end
subgraph Internal["Internal Security"]
APP --> ENC[Data Encryption]
ENC --> LOG[Audit Logging]
end
```
## 3.10 Error Handling Architecture
```mermaid
flowchart TB
E[Error Occurs] --> T{Error Type}
T -->|Validation| V[ValidationException]
T -->|Not Found| N[NotFoundException]
T -->|Auth| A[UnauthorizedException]
T -->|Business| B[BusinessException]
T -->|System| S[InternalException]
V --> F[Exception Filter]
N --> F
A --> F
B --> F
S --> F
F --> R[Formatted Response]
F --> L[Log Error]
L -->|Critical| AL[Alert]
```
## 3.11 Scalability Considerations
### Horizontal Scaling Strategy
```mermaid
flowchart TB
subgraph Scaling["Scaling Strategy"]
direction TB
subgraph Stateless["Stateless Components"]
API[API Servers]
WORKER[Queue Workers]
end
subgraph Stateful["Stateful Components"]
SESSION[Session Instances]
end
subgraph Shared["Shared State"]
DB[(Database)]
REDIS[(Redis)]
S3[(Object Storage)]
end
end
Stateless --> Shared
Stateful --> Shared
```
### Session Affinity
```mermaid
flowchart LR
subgraph Router["Request Router"]
R[Request] --> H{Has Session ID?}
H -->|Yes| A[Route to Affinity]
H -->|No| B[Round Robin]
end
A --> I1[Instance 1<br/>Session A, B]
A --> I2[Instance 2<br/>Session C, D]
B --> I1
B --> I2
```
---
## 3.12 Engine Abstraction Layer
> [!IMPORTANT]
> Engine abstraction is critical to mitigate **R001: WhatsApp Protocol Changes** in Risk Management. OpenWA ships two production-ready engines selectable via `ENGINE_TYPE`: `whatsapp-web.js` (default, Chromium/Puppeteer-based) and `baileys` (browser-free, WebSocket/Noise protocol). With the abstraction layer, adding further engines requires no changes to application code.
### Strategy Pattern for Engine
```mermaid
classDiagram
class IWhatsAppEngine {
<<interface>>
+initialize(callbacks): Promise~void~
+disconnect(): Promise~void~
+logout(): Promise~void~
+destroy(): Promise~void~
+forceDestroy(): Promise~void~
+getStatus(): EngineStatus
+getQRCode(): string | null
+requestPairingCode(phone): Promise~string~
+sendTextMessage(chatId, text): Promise~MessageResult~
+sendImageMessage(chatId, media): Promise~MessageResult~
}
class WhatsAppWebJsAdapter {
-client: Client
+initialize(callbacks): Promise~void~
+sendTextMessage(): Promise~MessageResult~
}
class BaileysAdapter {
-socket: WASocket
+initialize(callbacks): Promise~void~
+sendTextMessage(): Promise~MessageResult~
}
class EngineFactory {
+create(options: EngineCreateOptions): IWhatsAppEngine
}
IWhatsAppEngine <|.. WhatsAppWebJsAdapter
IWhatsAppEngine <|.. BaileysAdapter
EngineFactory --> IWhatsAppEngine
```
### Engine Interface Definition
Events are **not** delivered through an `on`/`off`/`once` emitter. Instead, the host passes a single
`EngineEventCallbacks` object to `initialize()`; the adapter invokes the registered callbacks for the
lifetime of the engine. Status is an `EngineStatus` **enum** (not a string union), `getQRCode()` is
**synchronous** (`string | null`), and there is no `connect()` / `isReady()` / `getAuthState()` — the
adapter connects inside `initialize()`.
```typescript
// engine/interfaces/whatsapp-engine.interface.ts
export enum EngineStatus {
DISCONNECTED = 'disconnected',
INITIALIZING = 'initializing',
QR_READY = 'qr_ready',
AUTHENTICATING = 'authenticating',
READY = 'ready',
FAILED = 'failed',
}
// All inbound signals arrive through callbacks supplied once to initialize().
export interface EngineEventCallbacks {
onQRCode?: (qr: string) => void;
onReady?: (phone: string, pushName: string) => void;
onMessage?: (message: IncomingMessage) => void;
onMessageCreate?: (message: IncomingMessage) => void; // outgoing (incl. linked-phone sends)
onMessageAck?: (messageId: string, status: DeliveryStatus) => void;
onMessageRevoked?: (message: RevokedMessage) => void;
onMessageReaction?: (event: ReactionEvent) => void;
onHistoryMessages?: (messages: IncomingMessage[]) => void; // bulk initial sync; persist, don't dispatch
onDisconnected?: (reason: string) => void; // recoverable -> reconnect
onStateChanged?: (state: EngineStatus) => void;
onError?: (reason: string) => void; // terminal init/auth failure
}
export interface IWhatsAppEngine {
// Lifecycle — connecting happens inside initialize(); callbacks are registered here.
initialize(callbacks: EngineEventCallbacks): Promise<void>;
disconnect(): Promise<void>; // close, keep session (reconnect without QR)
logout(): Promise<void>; // clear session (requires QR scan again)
destroy(): Promise<void>;
forceDestroy(): Promise<void>; // kill this engine's own resources, then graceful teardown
// Status / auth
getStatus(): EngineStatus;
getQRCode(): string | null; // synchronous
requestPairingCode(phoneNumber: string): Promise<string>;
getPhoneNumber(): string | null;
getPushName(): string | null;
// Messaging (selected)
sendTextMessage(chatId: string, text: string): Promise<MessageResult>;
sendImageMessage(chatId: string, media: MediaInput): Promise<MessageResult>;
sendLocationMessage(chatId: string, location: LocationInput): Promise<MessageResult>;
sendContactMessage(chatId: string, contact: ContactCard): Promise<MessageResult>;
// Contacts / groups / chats — see the interface file for the full method set.
getContacts(): Promise<Contact[]>;
getGroups(): Promise<Group[]>;
getChats(): Promise<ChatSummary[]>;
// ...
}
```
### Engine Factory
The factory resolves the engine through the **plugin loader**, not a hard-coded `switch`. The
configured engine (`engine.type`, default `'whatsapp-web.js'`) is read once in the constructor; the
built-in `whatsapp-web.js` and `baileys` plugins are registered and the configured one is enabled in
`onModuleInit()`. `create()` takes an **options object** (engine-neutral per-call config —
`sessionId` / `proxyUrl` / `proxyType`), not a `type` argument. There is no `EngineType` union, no
`switch`, and no `Unknown engine type` throw: if the plugin is unavailable it logs a warning and
**falls back** to constructing a `WhatsAppWebJsAdapter` directly. (A typo in `ENGINE_TYPE` is rejected
at boot by `validateEnv`, which whitelists `whatsapp-web.js` | `baileys`.)
```typescript
// engine/engine.factory.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IWhatsAppEngine } from './interfaces/whatsapp-engine.interface';
import { WhatsAppWebJsAdapter } from './adapters/whatsapp-web-js.adapter';
import { PluginLoaderService, PluginType, IEnginePlugin } from '../core/plugins';
export interface EngineCreateOptions {
sessionId: string;
proxyUrl?: string;
proxyType?: 'http' | 'https' | 'socks4' | 'socks5';
}
@Injectable()
export class EngineFactory implements OnModuleInit {
private readonly engineType: string;
constructor(
private readonly configService: ConfigService,
private readonly pluginLoader: PluginLoaderService,
/* ...message-store + lid-mapping deps... */
) {
this.engineType = this.configService.get<string>('engine.type') ?? 'whatsapp-web.js';
}
async onModuleInit(): Promise<void> {
// Register the built-in whatsapp-web.js + baileys engine plugins, then enable the configured one.
await this.registerBuiltInEngines();
}
create(options: EngineCreateOptions): IWhatsAppEngine {
const enginePlugin = this.pluginLoader.getPlugin(this.engineType);
if (enginePlugin?.instance && this.isEnginePlugin(enginePlugin.instance)) {
// Engine-specific config (e.g. Puppeteer) was handed to the plugin as an opaque blob at
// registration, so the factory passes only engine-neutral per-call options here.
return enginePlugin.instance.createEngine({
sessionId: options.sessionId,
proxyUrl: options.proxyUrl,
proxyType: options.proxyType,
}) as IWhatsAppEngine;
}
// Plugin missing -> warn and fall back to the direct whatsapp-web.js adapter (no throw).
return this.createFallbackEngine(options);
}
}
```
### WhatsApp-Web.js Adapter
```typescript
// engine/adapters/whatsapp-web-js.adapter.ts
import { Client, LocalAuth } from 'whatsapp-web.js';
import {
IWhatsAppEngine,
EngineEventCallbacks,
EngineStatus,
MessageResult,
} from '../interfaces/whatsapp-engine.interface';
export class WhatsAppWebJsAdapter implements IWhatsAppEngine {
private client: Client | null = null;
private status: EngineStatus = EngineStatus.DISCONNECTED;
private callbacks: EngineEventCallbacks = {};
// The host registers all event callbacks here; the adapter also connects inside initialize().
async initialize(callbacks: EngineEventCallbacks): Promise<void> {
this.callbacks = callbacks;
this.setStatus(EngineStatus.INITIALIZING);
this.client = new Client({
authStrategy: new LocalAuth({ clientId: this.sessionId, dataPath: this.sessionDataPath }),
puppeteer: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] },
});
this.setupEventHandlers();
await this.client.initialize();
}
private setupEventHandlers(): void {
this.client!.on('qr', (qr) => {
this.setStatus(EngineStatus.QR_READY);
this.callbacks.onQRCode?.(qr);
});
this.client!.on('ready', () => {
this.setStatus(EngineStatus.READY);
this.callbacks.onReady?.(this.phoneNumber ?? '', this.pushName ?? '');
});
this.client!.on('disconnected', (reason) => {
this.setStatus(EngineStatus.DISCONNECTED);
this.callbacks.onDisconnected?.(String(reason));
});
this.client!.on('message', (message) => {
this.callbacks.onMessage?.(this.toIncomingMessage(message)); // mapped to the neutral shape
});
}
private setStatus(status: EngineStatus): void {
this.status = status;
this.callbacks.onStateChanged?.(status);
}
async disconnect(): Promise<void> {
await this.client?.destroy(); // keep session; logout() clears it
this.setStatus(EngineStatus.DISCONNECTED);
}
async sendTextMessage(chatId: string, text: string): Promise<MessageResult> {
const message = await this.client!.sendMessage(chatId, text);
return { id: message.id._serialized, timestamp: message.timestamp };
}
// ... full method set per the interface
}
```
### Baileys Adapter (Alternative Engine)
```typescript
// engine/adapters/baileys.adapter.ts
import makeWASocket, { DisconnectReason, useMultiFileAuthState } from '@whiskeysockets/baileys';
import {
IWhatsAppEngine,
EngineEventCallbacks,
EngineStatus,
MessageResult,
} from '../interfaces/whatsapp-engine.interface';
export class BaileysAdapter implements IWhatsAppEngine {
private socket: ReturnType<typeof makeWASocket> | null = null;
private status: EngineStatus = EngineStatus.DISCONNECTED;
private callbacks: EngineEventCallbacks = {};
// Baileys connects during initialize(); callbacks are registered here, same as the wwebjs adapter.
async initialize(callbacks: EngineEventCallbacks): Promise<void> {
this.callbacks = callbacks;
this.setStatus(EngineStatus.INITIALIZING);
const { state, saveCreds } = await useMultiFileAuthState(`${this.authDir}/${this.sessionId}`);
this.socket = makeWASocket({ auth: state });
this.socket.ev.on('creds.update', saveCreds);
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.socket!.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
this.setStatus(EngineStatus.QR_READY);
this.callbacks.onQRCode?.(qr);
}
if (connection === 'open') {
this.setStatus(EngineStatus.READY);
this.callbacks.onReady?.(this.phoneNumber ?? '', this.pushName ?? '');
}
if (connection === 'close') {
const loggedOut = (lastDisconnect?.error as any)?.output?.statusCode === DisconnectReason.loggedOut;
this.setStatus(loggedOut ? EngineStatus.FAILED : EngineStatus.DISCONNECTED);
this.callbacks.onDisconnected?.(loggedOut ? 'logged_out' : 'connection_closed');
}
});
this.socket!.ev.on('messages.upsert', ({ messages }) => {
for (const msg of messages) {
if (!msg.key.fromMe) this.callbacks.onMessage?.(this.toIncomingMessage(msg)); // neutral ids
}
});
}
private setStatus(status: EngineStatus): void {
this.status = status;
this.callbacks.onStateChanged?.(status);
}
async sendTextMessage(chatId: string, text: string): Promise<MessageResult> {
const result = await this.socket!.sendMessage(chatId, { text });
return { id: result!.key.id!, timestamp: Math.floor(Date.now() / 1000) };
}
// ... full method set per the interface
}
```
### Engine Selection Configuration
```bash
# .env
ENGINE_TYPE=whatsapp-web.js # Options: whatsapp-web.js (default), baileys
# Switch to the browser-free engine
ENGINE_TYPE=baileys
```
### Migration Strategy
```mermaid
flowchart TB
subgraph Current["Available Engines"]
A[whatsapp-web.js\nPuppeteer-based\ndefault]
A2[Baileys\nWebSocket-based\nENGINE_TYPE=baileys]
end
subgraph Risk["Risk Detection"]
B{Protocol\nBreaking?}
end
subgraph Migration["Migration Path"]
C[Update whatsapp-web.js]
D[Switch to Baileys]
E[Community Fork]
end
subgraph Resolution["Resolution"]
F[Service Restored]
end
A --> B
B -->|Minor| C --> F
B -->|Major wwebjs| D --> F
B -->|Major Both| E --> F
```
### Engine Comparison
| Feature | whatsapp-web.js | Baileys |
|---------|-----------------|---------|
| **Protocol** | Web (Puppeteer) | Native WebSocket |
| **Resource Usage** | High (~500MB/session) | Low (~50MB/session) |
| **Stability** | Good | Good |
| **Community** | Large | Large |
| **Multi-device** | ✅ | ✅ |
| **QR Code** | ✅ | ✅ |
| **Phone Link** | ❌ | ✅ |
| **Maintenance** | Active | Active |
### Benefits of Abstraction
1. **Risk Mitigation** - Swap engines without changing application code
2. **Testing** - The single `IWhatsAppEngine` boundary makes the engine trivial to stub/mock in unit tests
3. **Flexibility** - Run different engines per deployment via `ENGINE_TYPE`
4. **Future-proof** - New engines register as plugins; no changes to application code
5. **Comparison** - Evaluate engine resource/behavior trade-offs per environment
---
## 3.13 Pluggable Adapters
OpenWA uses the adapter pattern for infrastructure components that can be swapped per deployment needs. This allows users with limited resources to run OpenWA without heavyweight external dependencies.
### Adapter Overview
```mermaid
flowchart TB
subgraph Core["OpenWA Core"]
APP[Application Logic]
end
subgraph Adapters["Pluggable Backends"]
subgraph Engine["WhatsApp Engine"]
E1[whatsapp-web.js]
E2[Baileys]
end
subgraph Database["Database"]
D1[SQLite]
D2[PostgreSQL]
end
subgraph Storage["Media Storage"]
S1[Local Filesystem]
S2[S3 / MinIO]
end
subgraph Cache["Cache"]
C1[Redis]
C2[Disabled - no-op]
end
end
APP --> Engine
APP --> Database
APP --> Storage
APP --> Cache
```
### Adapter Options
| Component | Options (`ENV`) | Default | Notes |
|-----------|---------|---------|-------|
| **WhatsApp Engine** | whatsapp-web.js, Baileys (`ENGINE_TYPE`) | whatsapp-web.js | Baileys is browser-free |
| **Database** | SQLite, PostgreSQL (`DATABASE_TYPE`) | SQLite | PostgreSQL for large-scale production |
| **Media Storage** | local, s3 (`STORAGE_TYPE`) | local | MinIO is the `s3` backend (`forcePathStyle`) |
| **Cache** | Redis or disabled (`REDIS_ENABLED`) | Disabled | When disabled/unreachable, cache fails open (no-op) |
### 3.13.1 Storage Service
Media storage is a **single service** (`src/common/storage/storage.service.ts`) that branches
internally on `storageType` — there is no `I*Adapter` interface, separate adapter classes, or a
`StorageFactory`. The two backends are `local` (the default; files under `./data/media`) and `s3`.
**MinIO is not a separate type** — it is the `s3` backend; the S3 client is always created with
`forcePathStyle: true`, which MinIO requires, and any S3-compatible endpoint works. The public method
set is `putFile` / `getFile` / `listFiles` / `createExportStream` (export) / `importFromStream`
(import), plus `getFileCount` and `getCurrentStorageType`.
```typescript
// src/common/storage/storage.service.ts
@Injectable()
export class StorageService {
private readonly storageType: string; // 'local' | 's3'
private readonly localPath: string;
private s3Client: S3Client | null = null;
constructor(private readonly configService: ConfigService) {
this.storageType = this.configService.get<string>('storage.type') || 'local';
this.localPath = this.configService.get<string>('storage.localPath') || './data/media';
if (this.storageType === 's3') {
const endpoint = process.env.S3_ENDPOINT; // S3 / MinIO endpoint
const accessKeyId = process.env.S3_ACCESS_KEY_ID; // legacy S3_ACCESS_KEY also read
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
if (endpoint && accessKeyId && secretAccessKey) {
this.s3Client = new S3Client({
endpoint,
region: process.env.S3_REGION || 'us-east-1',
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true, // Required for MinIO; harmless for AWS S3
});
// bucket auto-created if missing (HeadBucket -> CreateBucket)
}
}
if (!fs.existsSync(this.localPath)) fs.mkdirSync(this.localPath, { recursive: true });
}
// Both backends share one path-safety guard (isSafeStorageKey) at this boundary.
async putFile(filePath: string, data: Buffer): Promise<void> {
if (!isSafeStorageKey(filePath)) throw new Error(`Refusing unsafe storage key: ${filePath}`);
return this.storageType === 's3' && this.s3Client
? this.putS3File(filePath, data) // keyed under media/<filePath>
: this.putLocalFile(filePath, data);
}
async getFile(filePath: string): Promise<Buffer> { /* mirrors putFile */ }
async listFiles(): Promise<string[]> { /* local recurse, or S3 ListObjectsV2 under media/ */ }
// createExportStream(): tar.gz of all files; importFromStream(): extract with zip-bomb caps
}
```
### 3.13.2 Database Adapter
OpenWA supports SQLite for lightweight deployments and PostgreSQL for high-volume production.
#### Database Comparison
| Feature | SQLite | PostgreSQL |
|---------|--------|------------|
| **Setup** | Zero config | Requires server |
| **Concurrent writes** | Limited (1 writer) | Excellent |
| **Horizontal scaling** | ❌ | ✅ |
| **Table partitioning** | ❌ | ✅ |
| **Memory footprint** | ~10MB | ~100MB+ |
| **Backup** | Copy file | pg_dump |
| **Best for** | 1-5 sessions | 5+ sessions |
#### TypeORM Configuration
Database wiring lives inline in `AppModule` (`src/app.module.ts`) as two named
`TypeOrmModule.forRootAsync` connections — there is no standalone `getDatabaseConfig` helper. The
`data` connection is the one shown below; the `main` connection is always SQLite (auth + audit). The
`data` connection's type comes from `DATABASE_TYPE` (`sqlite` default, or `postgres`):
```typescript
// shape of the 'data' connection useFactory in src/app.module.ts
const dbType = configService.get<'sqlite' | 'postgres'>('dataDatabase.type', 'sqlite');
const baseConfig = {
entities: [/* session, webhook, message, template, engine entities */],
migrations: [__dirname + '/database/migrations/*{.ts,.js}'],
logging: configService.get<boolean>('dataDatabase.logging', false),
};
if (dbType === 'postgres') {
return {
...baseConfig, name: 'data', type: 'postgres',
host: configService.get('dataDatabase.host'),
port: configService.get('dataDatabase.port'),
username: configService.get('dataDatabase.username'),
password: configService.get('dataDatabase.password'),
database: configService.get('dataDatabase.name', 'openwa'),
synchronize: configService.get('dataDatabase.synchronize', false), // migrations in prod
migrationsRun: true,
extra: { max: configService.get('dataDatabase.poolSize', 10) },
};
}
// SQLite (default): migration-managed unless DATABASE_SYNCHRONIZE=true
const synchronize = configService.get<boolean>('dataDatabase.synchronize', false);
return {
...baseConfig, name: 'data', type: 'sqlite',
database: configService.get('dataDatabase.database', './data/openwa.sqlite'),
synchronize,
migrationsRun: !synchronize,
};
```
#### SQLite Considerations
> **Note:** OpenWA does not currently apply SQLite-specific concurrency hardening. There is **no**
> `journal_mode = WAL` PRAGMA, no `SqliteWriteQueueService`, and no application-level write
> serialization or session cap in the source. SQLite is used with TypeORM's defaults, so its standard
> single-writer behavior applies. For high write-concurrency or multi-session deployments, use
> PostgreSQL (`DATABASE_TYPE=postgres`). Cross-dialect schema differences are handled at migration
> time (see below), not by a runtime optimizations layer.
#### Migration Strategy
```typescript
// database/migrations/utils/database-aware-migration.ts
/**
* Helper for writing migrations compatible with SQLite and PostgreSQL
*/
export abstract class DatabaseAwareMigration {
protected isPostgres(queryRunner: QueryRunner): boolean {
return queryRunner.connection.options.type === 'postgres';
}
protected isSqlite(queryRunner: QueryRunner): boolean {
return queryRunner.connection.options.type === 'sqlite';
}
/**
* Generate UUID default based on database type
*/
protected getUuidDefault(queryRunner: QueryRunner): string {
if (this.isPostgres(queryRunner)) {
return 'gen_random_uuid()';
}
// SQLite: UUID must be generated at the application level
return '';
}
/**
* Get timestamp type based on database
*/
protected getTimestampType(queryRunner: QueryRunner): string {
if (this.isPostgres(queryRunner)) {
return 'TIMESTAMP WITH TIME ZONE';
}
return 'DATETIME';
}
}
```
### 3.13.3 Cache Service
There is **no** cache-manager / `CacheModuleOptions` / `redisStore` setup and **no** in-memory cache.
`CacheService` (`src/common/cache/cache.service.ts`) talks to **ioredis directly** and is gated by
`REDIS_ENABLED` (falling back to the `cache.enabled` config flag). When caching is disabled — or Redis
is unreachable — the service **fails open**: every read returns `null` and every write is a silent
no-op, so the app keeps serving from its source of truth. In other words, "no cache configured" means
**no cache** (recompute), not an in-process LRU. Cache is therefore a pure optimization layer (session
status/info/QR/list/stats, each with its own short TTL); it is never the source of truth.
```typescript
// src/common/cache/cache.service.ts
@Injectable()
export class CacheService implements OnModuleDestroy {
private redis: Redis | null = null;
private readonly enabled: boolean;
constructor(private readonly configService: ConfigService) {
// REDIS_ENABLED is the primary switch; cache.enabled is the legacy fallback.
this.enabled = process.env.REDIS_ENABLED === 'true' || configService.get<boolean>('cache.enabled', false);
// Lazy connect: the first isAvailable() call dials Redis (bounded retries).
}
async isAvailable(): Promise<boolean> {
if (!this.enabled) return false; // disabled -> always "no cache"
if (!this.redis) await this.tryConnect(); // bounded attempts
return this.ping();
}
// Fail-open reads/writes: unavailable Redis is a no-op, never an error to the caller.
async getSessionStatus(id: string): Promise<string | null> {
if (!(await this.isAvailable())) return null;
try { return await this.redis!.get(`session:${id}:status`); }
catch { return null; }
}
async setSessionStatus(id: string, status: string): Promise<void> {
if (!(await this.isAvailable())) return; // no-op when disabled/unreachable
try { await this.redis!.setex(`session:${id}:status`, /* TTL */ 300, status); }
catch { /* logged + swallowed */ }
}
}
```
### 3.13.4 Deployment Profiles
OpenWA provides several deployment profiles for different needs:
```mermaid
flowchart LR
subgraph Minimal["🪶 Minimal Profile"]
M1[SQLite]
M2[Local Storage]
M3[No Cache]
M4[Single Session]
end
subgraph Standard["⚡ Standard Profile"]
S1[PostgreSQL]
S2[Local Storage]
S3[Redis]
S4[Multi Session]
end
subgraph Enterprise["🏢 Enterprise Profile"]
E1[PostgreSQL Cluster]
E2[S3/MinIO]
E3[Redis Cluster]
E4[Horizontal Scaling]
end
```
| Profile | Database | Storage | Cache | Sessions | RAM | Use Case |
|---------|----------|---------|-------|----------|-----|----------|
| **Minimal** | SQLite | Local | None | 1-3 | 512MB | Personal bot, testing |
| **Standard** | PostgreSQL | Local | Redis | 5-10 | 2GB | Small business |
| **Enterprise** | PostgreSQL | S3/MinIO | Redis | 10+ | 4GB+ | Agency, high volume |
> Session counts are guidance only by default. Set `MAX_CONCURRENT_SESSIONS` to a positive integer
> to cap concurrently running or initializing engines; the default `0` keeps the historical
> unlimited behavior.
### Configuration Examples
#### Minimal Profile (.env)
```bash
# Database
DATABASE_TYPE=sqlite
DATABASE_NAME=./data/openwa.sqlite
# Storage
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./data/media
# Cache: omit / leave Redis disabled -> the cache layer no-ops (no in-memory cache)
REDIS_ENABLED=false
```
#### Standard Profile (.env)
```bash
# Database (Postgres uses discrete host/port/credentials, not a single URL)
DATABASE_TYPE=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=openwa
DATABASE_USERNAME=openwa
DATABASE_PASSWORD=password
# Storage
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./data/media
# Cache
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
```
#### Enterprise Profile (.env)
```bash
# Database
DATABASE_TYPE=postgres
DATABASE_HOST=db-cluster
DATABASE_PORT=5432
DATABASE_NAME=openwa
DATABASE_USERNAME=openwa
DATABASE_PASSWORD=password
DATABASE_POOL_SIZE=50
# Storage (S3 or any S3-compatible endpoint; MinIO uses the same vars)
STORAGE_TYPE=s3
S3_BUCKET=openwa-media
S3_REGION=ap-southeast-1
S3_ACCESS_KEY_ID=xxx
S3_SECRET_ACCESS_KEY=xxx
S3_ENDPOINT=https://s3.ap-southeast-1.amazonaws.com
# For MinIO, point S3_ENDPOINT at it (path-style is always on):
# S3_ENDPOINT=http://minio:9000
# Cache
REDIS_ENABLED=true
REDIS_HOST=redis-cluster
REDIS_PORT=6379
```
> OpenWA runs as a single API instance per session-data volume; there is no cluster-mode flag.
> "Enterprise" here describes vertical headroom (RAM, Postgres, S3, Redis), not multi-replica
> horizontal scaling — see the single-instance note in §3.2.
### Choosing a Profile
OpenWA does not auto-detect a profile at runtime; pick one by available resources and expected load:
| Available RAM | Suggested profile | Backends |
|---------------|-------------------|----------|
| < ~1 GB | Minimal | SQLite + Local Storage, Redis disabled (no cache) |
| ~1–4 GB | Standard | PostgreSQL + Local Storage + Redis |
| > ~4 GB | Enterprise | PostgreSQL + S3/MinIO + Redis |
All profiles still run as a single API instance per session-data volume (see §3.2). Enterprise here
means more vertical headroom and external backends, not multi-replica clustering.
---
<div align="center">
[← 02 - Requirements Specification](./02-requirements-specification.md) · [Documentation Index](./README.md) · [Next: 04 - Security Design →](./04-security-design.md)
</div>