feat: add plans and docs

This commit is contained in:
Edqe14 2026-05-14 20:17:00 +07:00
parent 7af75b6bb6
commit b08546294a
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
6 changed files with 498 additions and 0 deletions

View file

@ -22,6 +22,38 @@ Run a single test file: `bun --bun run test src/path/to/file.test.ts`
Add shadcn components: `pnpm dlx shadcn@latest add <component>` (uses pnpm, not bun)
## Workplan
Milestone-by-milestone plan lives in `.playground/plan/`.
**Before starting any session:**
1. Read `.playground/plan/STATUS.md` — current milestone, blockers, open concerns.
2. Open the relevant milestone file (e.g. `M1-foundations.md`).
3. Work tasks top-to-bottom; check boxes as you complete them.
4. After each task or at session end, update `STATUS.md`: progress, new blockers, decisions made.
**Status conventions in milestone files:**
- `⬜` ready to start
- `🟡` concern / needs decision
- `🔴` blocked
- `✅` done (or check the checkbox)
**Handoff rule:** never mark a task done unless its acceptance criteria are met. Leave a note in `STATUS.md` if partially done.
## Documentation
Project docs live in `docs/`:
| File | Contents |
|------|----------|
| `docs/architecture.md` | System overview, component map, key files |
| `docs/api.md` | REST endpoint reference |
| `docs/webhooks.md` | Event catalog, HMAC verification snippets |
| `docs/security.md` | Encryption model, key rotation, audit log |
| `docs/deployment.md` | Docker Compose deploy, backup, restore, upgrade |
Update docs when adding endpoints, changing the schema, or changing security behavior.
## Architecture
**Stack:** TanStack Start (SSR) + TanStack Router (file-based) + Drizzle ORM + PostgreSQL + Tailwind CSS v4 + shadcn/ui + Nitro (server adapter) + Vite

91
docs/api.md Normal file
View file

@ -0,0 +1,91 @@
# API Reference
Full interactive spec: `GET /openapi.json` → render at `GET /docs` (available after M6).
## Authentication
All API endpoints require:
```
Authorization: Bearer wak_<env>_<32-char-random>
```
Keys are per-App. All requests scoped to the authenticating App — no cross-app access.
## Rate limits
Default: 60 req/s burst, 10 req/s sustained per API key. Configurable. Returns `429` with `Retry-After` header on exceed.
---
## Sessions
| Method | Path | Description |
| ------ | ----------------------------- | ------------------------------------ |
| POST | `/v1/sessions` | Create session |
| GET | `/v1/sessions` | List sessions |
| GET | `/v1/sessions/:id` | Session detail + status |
| DELETE | `/v1/sessions/:id` | Delete + wipe credentials |
| POST | `/v1/sessions/:id/disconnect` | Graceful disconnect |
| GET | `/v1/sessions/:id/qr` | Stream QR code (SSE) |
| POST | `/v1/sessions/:id/pair` | Request pairing code `{phone}` |
| GET | `/v1/sessions/:id/health` | Status + last_seen + reconnect count |
**Session states:** `PENDING_QR` → `CONNECTING` → `CONNECTED` → `DISCONNECTED` → `DEAD` / `LOGGED_OUT`
---
## Messages
| Method | Path | Description |
| ------ | --------------------------------------- | ------------------------------------- |
| POST | `/v1/sessions/:id/messages/text` | Send text `{to, text}` |
| POST | `/v1/sessions/:id/messages/image` | Send image (multipart or `{to, url}`) |
| POST | `/v1/sessions/:id/messages/video` | Send video |
| POST | `/v1/sessions/:id/messages/document` | Send document `{to, url, filename}` |
| POST | `/v1/sessions/:id/messages/audio` | Send audio `{to, url, voice_note?}` |
| POST | `/v1/sessions/:id/messages/broadcast` | Fan-out to multiple JIDs |
| POST | `/v1/sessions/:id/presence` | Set presence `{to_jid, type}` |
| POST | `/v1/sessions/:id/messages/:msgId/read` | Mark read |
| GET | `/v1/sessions/:id/messages` | List `?since=&limit=&direction=` |
---
## Contacts
| Method | Path | Description |
| ---------------- | ------------------------------------------- | -------------------------------------- |
| GET | `/v1/contacts` | List `?q=&tag=&group_id=` |
| POST | `/v1/contacts` | Create |
| GET | `/v1/contacts/:id` | Detail |
| PATCH | `/v1/contacts/:id` | Update |
| DELETE | `/v1/contacts/:id` | Delete |
| POST | `/v1/contacts/import` | CSV import (multipart) `?dry_run=true` |
| GET | `/v1/contacts/import/:jobId` | Import job status |
| POST | `/v1/contacts/sync-from-whatsapp` | Trigger WhatsApp address book sync |
| GET | `/v1/contact-groups` | List groups |
| POST | `/v1/contact-groups` | Create group |
| GET/PATCH/DELETE | `/v1/contact-groups/:id` | Group CRUD |
| POST | `/v1/contact-groups/:id/members` | Bulk add contacts |
| DELETE | `/v1/contact-groups/:id/members/:contactId` | Remove member |
---
## Webhooks
| Method | Path | Description |
| ---------------- | ----------------------------- | ----------------------------------- |
| GET | `/v1/webhooks` | List endpoints |
| POST | `/v1/webhooks` | Register `{url, events[], secret?}` |
| GET/PATCH/DELETE | `/v1/webhooks/:id` | Endpoint CRUD |
| GET | `/v1/webhooks/:id/deliveries` | Delivery log (last 100) |
| POST | `/v1/webhooks/:id/test` | Fire test payload |
---
## Health
| Method | Path | Description |
| ------ | --------- | ---------------------- |
| GET | `/health` | Liveness |
| GET | `/ready` | Readiness (DB + Redis) |

60
docs/architecture.md Normal file
View file

@ -0,0 +1,60 @@
# Architecture
**Stack:** Node.js (Fastify) API + TanStack Start dashboard + PostgreSQL + Redis (BullMQ) + Baileys
```
Consumer App
│ Authorization: Bearer wak_*
▼
Fastify REST API ──────────────────────────┐
│ │
├── Session Manager (Baileys) │
│ ├── QR / pairing onboarding │
│ ├── Encrypted creds in PG │
│ └── Auto-reconnect │
│ │
├── BullMQ workers │
│ ├── send-queue │
│ └── webhook-delivery-queue │
│ ▼
└── PostgreSQL Redis 7+
(Drizzle ORM) (BullMQ + cache)
TanStack Start Dashboard (Platform Operator only)
├── /apps – tenant management
├── /sessions – session lifecycle + QR UI
├── /contacts – contact book + CSV import
├── /webhooks – endpoint CRUD + delivery log
├── /monitoring – charts (from PG aggregates + Redis)
└── /audit-log – append-only admin actions
Media Storage (pluggable)
├── local – STORAGE_LOCAL_PATH, served via Fastify static
└── s3 – MinIO / R2 / AWS S3 via presigned URLs
Observability
└── OpenTelemetry SDK → OTLP/HTTP → external collector
```
## Multi-tenancy
Every data row (except `users` and `apps`) carries `app_id`. API key auth resolves `app_id` at request time. No cross-app reads.
## Encryption model
- **KEK** — `MASTER_ENCRYPTION_KEY` (env, base64 32 bytes). Never leaves host.
- **DEK** — one per App, generated at creation, AES-256-GCM-wrapped by KEK, stored in `apps.dek_encrypted`.
- **Encrypted columns** — session credentials, contact PII (name/phone/email/custom_fields), webhook secrets — all encrypted with App DEK.
- **Blind index** — phone lookup uses `HMAC-SHA256(e164, index_key)` stored in `contacts.phone_blind_index`.
## Key files
| Path | Purpose |
| ---------------------------- | -------------------------------------------- |
| `src/db/schema.ts` | Drizzle schema (single source of truth) |
| `src/lib/crypto.ts` | AES-256-GCM helpers, DEK wrap/unwrap |
| `src/lib/blindIndex.ts` | HMAC blind index for phone lookups |
| `src/lib/baileys/` | Baileys wrapper + session manager |
| `src/lib/storage/` | Pluggable media storage drivers |
| `src/lib/events/dispatch.ts` | Event → webhook fan-out |
| `src/routes/` | TanStack Start file-based routes (dashboard) |

93
docs/deployment.md Normal file
View file

@ -0,0 +1,93 @@
# Deployment Guide
## Prerequisites
- Docker + Docker Compose v2
- A server with ≥ 2 vCPU, ≥ 4 GB RAM
- A domain name (for TLS)
## Quick start
```bash
git clone <repo>
cd wahoo
cp .env.example .env
# Edit .env — minimum required vars below
docker compose up -d
```
## Required environment variables
```env
DATABASE_URL=postgres://wahoo:secret@postgres:5432/wahoo
REDIS_URL=redis://redis:6379
# Generate: openssl rand -base64 32
MASTER_ENCRYPTION_KEY=<base64 32 bytes>
# local or s3
STORAGE_DRIVER=local
STORAGE_LOCAL_PATH=/data/media
# For s3 driver:
# STORAGE_S3_ENDPOINT=https://...
# STORAGE_S3_BUCKET=wahoo-media
# STORAGE_S3_ACCESS_KEY=...
# STORAGE_S3_SECRET_KEY=...
DASHBOARD_BASE_URL=https://your-domain.example.com
# Optional
MAX_SESSIONS_PER_APP=25
WEBHOOK_LOG_RETAIN=100
MEDIA_TTL_DAYS=30
OTEL_EXPORTER_OTLP_ENDPOINT= # leave empty to disable
```
> **Warning:** `MASTER_ENCRYPTION_KEY` is the root of all encryption. If lost, all session credentials and PII are unrecoverable. Back it up to a secrets manager before first use.
## TLS / reverse proxy
TLS termination is expected at your reverse proxy. Caddy example:
```caddyfile
your-domain.example.com {
reverse_proxy api:3001
}
```
## Backup procedure
Back up all three:
1. **PostgreSQL dump** — `pg_dump $DATABASE_URL > backup.sql`
2. **Media storage** — `rsync -av /data/media/ backup/media/` (or S3 snapshot)
3. **Master key** — `MASTER_ENCRYPTION_KEY` from `.env` stored in a secrets manager
## Restore to new host
1. Provision new host, install Docker.
2. Clone repo, copy `.env` with original `MASTER_ENCRYPTION_KEY`.
3. Restore Postgres: `psql $DATABASE_URL < backup.sql`
4. Restore media to `STORAGE_LOCAL_PATH` (or point to S3 bucket).
5. `docker compose up -d` — all sessions resume automatically.
## Encryption key rotation
1. Ensure Postgres + media are backed up.
2. Dashboard → App → "Rotate Encryption Key".
3. Monitor rotation job progress in dashboard.
4. After completion, old DEK is destroyed.
> Do not interrupt a rotation job in progress.
## Upgrading
```bash
git pull
docker compose pull
docker compose up -d
bun --bun run db:migrate # inside api container or locally
```
Pin Baileys version in `package.json` — do not auto-upgrade. Check release notes for protocol changes before upgrading.

64
docs/security.md Normal file
View file

@ -0,0 +1,64 @@
# Security Model
## Encryption at rest
All sensitive data encrypted with AES-256-GCM before writing to PostgreSQL.
**Key hierarchy:**
```
MASTER_ENCRYPTION_KEY (KEK) ← env var, base64 32 bytes
└── wraps App DEK (one per App)
├── session credentials
├── contact PII (name, phone, email, custom_fields)
└── webhook signing secrets
```
**Phone lookup:** `HMAC-SHA256(e164_phone, index_key)` stored as blind index. Enables exact-match search without decrypting the phone column.
## API authentication
- Header: `Authorization: Bearer wak_<env>_<32-char-random>`
- Random portion stored as Argon2id hash — plaintext never persisted after creation.
- Multiple active keys per App for zero-downtime rotation.
- Rate limit: 60 req/s burst / 10 req/s sustained (configurable).
## Dashboard authentication
- Argon2id password hashing.
- Optional TOTP 2FA.
- Session cookies: HttpOnly, Secure, SameSite=Lax.
## Webhook payload integrity
Every delivery signed: `HMAC-SHA256(raw_body + "." + timestamp, endpoint_secret)`.
Header: `X-Signature: sha256=<hex>`.
Verify (Node.js):
```js
const crypto = require('crypto')
function verify(secret, body, timestamp, sig) {
const expected = crypto
.createHmac('sha256', secret)
.update(body + '.' + timestamp)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}
```
## Key rotation
Admin action `POST /v1/admin/apps/:id/rotate-encryption-key` re-encrypts all app data with a new DEK in a background job. Old DEK destroyed after job completes. See `docs/deployment.md` for backup procedure — **back up master key and Postgres before rotating**.
## Network isolation
All inter-service traffic stays on Docker network. Only API + dashboard ports exposed externally. TLS termination at reverse proxy (Caddy / Traefik / nginx).
## Secrets never logged
Structured logger uses an explicit field allowlist — no API keys, DEKs, or signing secrets appear in logs (SEC-6).
## Audit log
Append-only `audit_log` table. Captured actions: login, App CRUD, ApiKey create/rotate/revoke, Session create/delete, webhook CRUD, encryption key rotation, contact bulk ops, config changes.

158
docs/webhooks.md Normal file
View file

@ -0,0 +1,158 @@
# Webhooks
## Registering an endpoint
```http
POST /v1/webhooks
Authorization: Bearer wak_prod_...
{
"url": "https://your-app.example.com/hooks/whatsapp",
"events": ["message.received", "session.status"],
"secret": "optional-custom-secret"
}
```
If `secret` is omitted, one is generated. **Store it — shown once.**
---
## Event types
| Event | When fired |
| ------------------- | ------------------------------------- |
| `session.status` | Any session state transition |
| `session.deleted` | Session deleted via API |
| `message.received` | Inbound message |
| `message.sent` | Outbound message accepted by WhatsApp |
| `message.delivered` | WhatsApp delivery receipt received |
| `message.read` | Read receipt received |
| `message.failed` | Send failed |
| `presence.update` | Contact presence change |
| `contact.created` | New contact added |
| `contact.updated` | Contact modified |
---
## Payload structure
```json
{
"event": "message.received",
"event_id": "evt_01hw...",
"app_id": "app_01hw...",
"timestamp": "2026-05-14T10:00:00Z",
"data": {}
}
```
`data` shape varies per event type — see `/docs` (OpenAPI) for full schemas.
---
## Standard headers
Every delivery includes:
```
X-Event-Id: evt_01hw...
X-Event-Type: message.received
X-App-Id: app_01hw...
X-Signature: sha256=<hex>
X-Timestamp: 1715684400
User-Agent: wagateway/1.0
Content-Type: application/json
```
---
## Verifying signatures
Signature covers: `raw_body + "." + X-Timestamp`.
**Node.js**
```js
const crypto = require('crypto')
function verifyWebhook(secret, rawBody, timestamp, signature) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody + '.' + timestamp)
.digest('hex')
const received = signature.replace('sha256=', '')
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex'),
)
}
```
**Python**
```python
import hmac, hashlib
def verify_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body + b'.' + timestamp.encode(), hashlib.sha256
).hexdigest()
received = signature.replace('sha256=', '')
return hmac.compare_digest(expected, received)
```
**Go**
```go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func VerifyWebhook(secret, body, timestamp, signature string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body + "." + timestamp))
expected := hex.EncodeToString(mac.Sum(nil))
received := strings.TrimPrefix(signature, "sha256=")
return hmac.Equal([]byte(expected), []byte(received))
}
```
**PHP**
```php
function verifyWebhook(string $secret, string $rawBody, string $timestamp, string $signature): bool {
$expected = hash_hmac('sha256', $rawBody . '.' . $timestamp, $secret);
$received = ltrim($signature, 'sha256=');
return hash_equals($expected, $received);
}
```
---
## Retry schedule
Failed deliveries retry with exponential backoff:
| Attempt | Delay after previous |
| ------- | -------------------- |
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
After 50 consecutive failures the endpoint is marked **unhealthy** and delivery pauses. Re-enable from the dashboard.
---
## Replay / testing
```http
POST /v1/webhooks/:id/test
```
Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response.