feat: add plans and docs
This commit is contained in:
parent
7af75b6bb6
commit
b08546294a
6 changed files with 498 additions and 0 deletions
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -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
91
docs/api.md
Normal 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
60
docs/architecture.md
Normal 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
93
docs/deployment.md
Normal 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
64
docs/security.md
Normal 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
158
docs/webhooks.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue