Compare commits

...

10 commits

Author SHA1 Message Date
0082802bac
chore: add license 2026-05-15 20:26:47 +07:00
e634ffddf2
refactor: improvement code review 2026-05-15 20:25:20 +07:00
3bbe2ac108
feat: implement messaging features 2026-05-15 16:38:42 +07:00
46567538dc
fix: eslint 2026-05-15 14:13:08 +07:00
c37f8f77c2
style: format prettier eslint 2026-05-15 04:06:45 +07:00
0aef5bca86
feat: session manager 2026-05-15 03:50:46 +07:00
8a84a660b3
feat: session management 2026-05-14 23:01:10 +07:00
8a8420c4e3
feat: implement foundation 2026-05-14 21:12:46 +07:00
b08546294a
feat: add plans and docs 2026-05-14 20:17:00 +07:00
7af75b6bb6
feat: add base claudemd 2026-05-14 19:57:06 +07:00
133 changed files with 14694 additions and 298 deletions

View file

@ -16,11 +16,5 @@
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"eslint",
"nitro",
"shadcn",
"drizzle",
"table"
]
}
"chosenAddOns": ["eslint", "nitro", "shadcn", "drizzle", "table"]
}

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
.output
drizzle
.env.local
.env
.playground
docs

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=wahoo
POSTGRES_PASSWORD=wahoo
POSTGRES_DB=wahoo
# Redis
REDIS_URL=redis://localhost:6379
# Encryption — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
MASTER_ENCRYPTION_KEY=
# Session — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
SESSION_SECRET=
# Server
PORT=3000
NODE_ENV=development
# Optional: Caddy HTTPS
DOMAIN=example.com

3
.gitignore vendored
View file

@ -11,3 +11,6 @@ dist-ssr
.vinxi
__unconfig*
todos.json
.playground
test-results
uploads

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false
}

121
CLAUDE.md Normal file
View file

@ -0,0 +1,121 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
bun --bun run dev # start dev server on :3000
bun --bun run build # production build
bun --bun run test # run vitest
bun --bun run lint # eslint
bun --bun run format # prettier + eslint --fix
bun --bun run check # prettier check only
bun --bun run db:generate # generate drizzle migration
bun --bun run db:migrate # run migrations
bun --bun run db:push # push schema changes (no migration file)
bun --bun run db:studio # open drizzle studio
```
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
**Path alias:** `#/*` → `./src/*` (e.g. `import { cn } from '#/lib/utils'`)
**Routing** — file-based under `src/routes/`. `routeTree.gen.ts` is auto-generated by the router plugin on dev/build — never edit it manually. Root layout and shell HTML live in `src/routes/__root.tsx` via `shellComponent`.
**Server functions** — use `createServerFn` from `@tanstack/react-start` for server-side logic callable from client components. Route loaders run server-side automatically.
**Database** — singleton `db` exported from `src/db/index.ts`, schema defined in `src/db/schema.ts`, migrations output to `./drizzle/`. DB credentials read from `DATABASE_URL` in `.env.local` (takes priority) or `.env`.
**UI components** — shadcn "new-york" style, zinc base color, CSS variables enabled. Components install to `#/components/ui`. Use `cn()` from `#/lib/utils` for className merging.
## Code Style
### Domain actions
All DB logic lives in `src/lib/actions/`. Route handlers must not import from `#/db` directly (exception: `src/routes/api/ready.ts`).
| File | Domain |
| ---------------------- | ----------------- |
| `actions/apps.ts` | App CRUD |
| `actions/api-keys.ts` | API key lifecycle |
| `actions/sessions.ts` | Session lifecycle |
| `actions/auth.ts` | User lookup, TOTP |
| `actions/audit-log.ts` | Audit log queries |
**Scope pattern** — when a function needs different authorization checks (user-owned vs app-scoped), use a discriminated union scope parameter instead of separate functions:
```ts
type Scope = { appId: string } | { userId: string };
export async function findSession(
sessionId: string,
scope: Scope,
): Promise<Session | null>;
```
**Optional ownership** — when two callers share the same query but one adds an ownership WHERE, use an optional param:
```ts
export async function createSession(
appId: string,
via: "qr" | "pairing",
ownerUserId?: string,
);
// admin: createSession(appId, via, sess.userId)
// api key: createSession(appId, via)
```
**Result type** — actions that can fail return a discriminated union, not thrown errors:
```ts
return { ok: false, error: "App not found", status: 404 };
return { ok: true, session };
// route: if (!result.ok) return jsonError(result.error, result.status)
```
**Audit context** — actions that write audit logs accept an optional `audit?: { ip?: string; userAgent?: string }` param. Routes extract these from request headers and pass them through.
### Route handlers
Routes handle: auth check → body parse → input validation → call action → map to HTTP response. No DB calls, no business logic inline.

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM oven/bun:1-alpine AS builder
RUN apk add --no-cache python3 make g++ linux-headers
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun --bun run build
# ---- runtime ----
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/.output .output
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Joshua Tanri
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,4 +1,4 @@
Welcome to your new TanStack Start app!
Welcome to your new TanStack Start app!
# Getting Started
@ -40,7 +40,6 @@ If you prefer not to use Tailwind CSS:
## Linting & Formatting
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
```bash
@ -49,7 +48,6 @@ bun --bun run format
bun --bun run check
```
## Deploy with Nitro
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
@ -63,7 +61,6 @@ The build output is a self-contained Node server. To deploy, push the `dist/` di
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
@ -72,8 +69,6 @@ Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
pnpm dlx shadcn@latest add button
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
@ -111,14 +106,14 @@ In the File Based Routing setup the layout is located in `src/routes/__root.tsx`
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "My App" },
],
}),
shellComponent: ({ children }) => (
@ -138,7 +133,7 @@ export const Route = createRootRoute({
</body>
</html>
),
})
});
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
@ -148,23 +143,23 @@ More information on layouts can be found in the [Layouts documentation](https://
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
import { createServerFn } from "@tanstack/react-start";
const getServerTime = createServerFn({
method: 'GET',
method: "GET",
}).handler(async () => {
return new Date().toISOString()
})
return new Date().toISOString();
});
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
const [time, setTime] = useState("");
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
getServerTime().then(setTime);
}, []);
return <div>Server time: {time}</div>;
}
```
@ -173,16 +168,16 @@ function MyComponent() {
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { createFileRoute } from "@tanstack/react-router";
import { json } from "@tanstack/react-start";
export const Route = createFileRoute('/api/hello')({
export const Route = createFileRoute("/api/hello")({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
GET: () => json({ message: "Hello, World!" }),
},
},
})
});
```
## Data Fetching
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/people')({
export const Route = createFileRoute("/people")({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
const response = await fetch("https://swapi.dev/api/people");
return response.json();
},
component: PeopleComponent,
})
});
function PeopleComponent() {
const data = Route.useLoaderData()
const data = Route.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
);
}
```

587
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -18,4 +18,4 @@
"hooks": "#/hooks"
},
"iconLibrary": "lucide"
}
}

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
services:
api:
build: .
ports:
- "3000:3000"
env_file:
- path: .env.local
required: false
- path: .env
required: false
environment:
- POSTGRES_HOST=postgres
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1))",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
postgres:
image: postgres:18-alpine
env_file:
- path: .env.local
required: false
- path: .env
required: false
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:

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.

View file

@ -1,13 +1,26 @@
import { config } from 'dotenv'
import { defineConfig } from 'drizzle-kit'
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
config({ path: ['.env.local', '.env'] })
config({ path: [".env.local", ".env"] });
const {
POSTGRES_USER,
POSTGRES_PASSWORD,
POSTGRES_HOST,
POSTGRES_PORT,
POSTGRES_DB,
} = process.env;
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
host: POSTGRES_HOST ?? "localhost",
port: Number(POSTGRES_PORT ?? 5432),
user: POSTGRES_USER ?? "wahoo",
password: POSTGRES_PASSWORD ?? "wahoo",
database: POSTGRES_DB ?? "wahoo",
ssl: false,
},
})
});

View file

@ -0,0 +1,51 @@
CREATE TYPE "public"."actor_type" AS ENUM('user', 'api_key');--> statement-breakpoint
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"key_hash" text NOT NULL,
"key_prefix" text NOT NULL,
"label" text DEFAULT '' NOT NULL,
"revoked_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "apps" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"owner_user_id" uuid NOT NULL,
"dek_encrypted" text NOT NULL,
"max_sessions" text DEFAULT '10' NOT NULL,
"deleted_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_type" "actor_type" NOT NULL,
"actor_id" uuid NOT NULL,
"action" text NOT NULL,
"resource_type" text NOT NULL,
"resource_id" text,
"ip" text,
"user_agent" text,
"diff_before" jsonb,
"diff_after" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"totp_secret" text,
"totp_enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apps" ADD CONSTRAINT "apps_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_keys_app_id_idx" ON "api_keys" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_type","actor_id");--> statement-breakpoint
CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint
CREATE INDEX "audit_log_created_at_idx" ON "audit_log" USING btree ("created_at");

View file

@ -0,0 +1,27 @@
CREATE TYPE "public"."session_status" AS ENUM('PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD');--> statement-breakpoint
CREATE TABLE "session_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"key_type" text NOT NULL,
"key_id" text NOT NULL,
"value_encrypted" text NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_keys_unique" UNIQUE("session_id","key_type","key_id")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"status" "session_status" DEFAULT 'PENDING_QR' NOT NULL,
"phone_number" text,
"display_name" text,
"credentials_encrypted" text,
"reconnect_count" integer DEFAULT 0 NOT NULL,
"last_seen" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "session_keys" ADD CONSTRAINT "session_keys_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sessions_app_id_idx" ON "sessions" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "sessions_status_idx" ON "sessions" USING btree ("status");

View file

@ -0,0 +1,31 @@
CREATE TYPE "public"."message_direction" AS ENUM('in', 'out');--> statement-breakpoint
CREATE TYPE "public"."message_status" AS ENUM('queued', 'sent', 'delivered', 'read', 'failed');--> statement-breakpoint
CREATE TYPE "public"."message_type" AS ENUM('text', 'image', 'video', 'document', 'audio');--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"session_id" uuid NOT NULL,
"whatsapp_msg_id" text NOT NULL,
"direction" "message_direction" NOT NULL,
"type" "message_type" NOT NULL,
"to_jid" text,
"from_jid" text,
"body_encrypted" text,
"media_url" text,
"media_mime" text,
"media_key" text,
"media_size" bigint,
"status" "message_status" DEFAULT 'queued' NOT NULL,
"is_group" boolean DEFAULT false NOT NULL,
"quoted_msg_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "auto_read" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "messages_session_id_idx" ON "messages" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "messages_app_id_idx" ON "messages" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "messages_created_at_idx" ON "messages" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "messages_whatsapp_msg_id_idx" ON "messages" USING btree ("whatsapp_msg_id");

View file

@ -0,0 +1,3 @@
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DATA TYPE integer;--> statement-breakpoint
ALTER TABLE "apps" ALTER COLUMN "max_sessions" SET DEFAULT 10;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "last_used_totp_token" text;

View file

@ -0,0 +1,369 @@
{
"id": "26e57c54-3075-4308-8de3-493327b47e7f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'10'"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": ["user", "api_key"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,562 @@
{
"id": "74058639-82f8-4a6d-88b8-8776d8badccc",
"prevId": "26e57c54-3075-4308-8de3-493327b47e7f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'10'"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session_keys": {
"name": "session_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_type": {
"name": "key_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value_encrypted": {
"name": "value_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"session_keys_session_id_sessions_id_fk": {
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": ["session_id", "key_type", "key_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "session_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'PENDING_QR'"
},
"phone_number": {
"name": "phone_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"credentials_encrypted": {
"name": "credentials_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"reconnect_count": {
"name": "reconnect_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_app_id_idx": {
"name": "sessions_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"sessions_status_idx": {
"name": "sessions_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_app_id_apps_id_fk": {
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": ["user", "api_key"]
},
"public.session_status": {
"name": "session_status",
"schema": "public",
"values": [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,793 @@
{
"id": "20e640d9-30d1-49e7-a0a0-64b86ebd08fc",
"prevId": "74058639-82f8-4a6d-88b8-8776d8badccc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'10'"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"whatsapp_msg_id": {
"name": "whatsapp_msg_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"direction": {
"name": "direction",
"type": "message_direction",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "message_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"to_jid": {
"name": "to_jid",
"type": "text",
"primaryKey": false,
"notNull": false
},
"from_jid": {
"name": "from_jid",
"type": "text",
"primaryKey": false,
"notNull": false
},
"body_encrypted": {
"name": "body_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_url": {
"name": "media_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_mime": {
"name": "media_mime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_key": {
"name": "media_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_size": {
"name": "media_size",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "message_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"is_group": {
"name": "is_group",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"quoted_msg_id": {
"name": "quoted_msg_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"messages_session_id_idx": {
"name": "messages_session_id_idx",
"columns": [
{
"expression": "session_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_app_id_idx": {
"name": "messages_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_created_at_idx": {
"name": "messages_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_whatsapp_msg_id_idx": {
"name": "messages_whatsapp_msg_id_idx",
"columns": [
{
"expression": "whatsapp_msg_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"messages_app_id_apps_id_fk": {
"name": "messages_app_id_apps_id_fk",
"tableFrom": "messages",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"messages_session_id_sessions_id_fk": {
"name": "messages_session_id_sessions_id_fk",
"tableFrom": "messages",
"tableTo": "sessions",
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session_keys": {
"name": "session_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_type": {
"name": "key_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value_encrypted": {
"name": "value_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"session_keys_session_id_sessions_id_fk": {
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": ["session_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": ["session_id", "key_type", "key_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "session_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'PENDING_QR'"
},
"phone_number": {
"name": "phone_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"credentials_encrypted": {
"name": "credentials_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"reconnect_count": {
"name": "reconnect_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"auto_read": {
"name": "auto_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_app_id_idx": {
"name": "sessions_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"sessions_status_idx": {
"name": "sessions_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_app_id_apps_id_fk": {
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": ["user", "api_key"]
},
"public.message_direction": {
"name": "message_direction",
"schema": "public",
"values": ["in", "out"]
},
"public.message_status": {
"name": "message_status",
"schema": "public",
"values": ["queued", "sent", "delivered", "read", "failed"]
},
"public.message_type": {
"name": "message_type",
"schema": "public",
"values": ["text", "image", "video", "document", "audio"]
},
"public.session_status": {
"name": "session_status",
"schema": "public",
"values": [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,847 @@
{
"id": "9ada70d3-912d-49ea-ab7d-a01d95e09c44",
"prevId": "20e640d9-30d1-49e7-a0a0-64b86ebd08fc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": [
"owner_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"whatsapp_msg_id": {
"name": "whatsapp_msg_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"direction": {
"name": "direction",
"type": "message_direction",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "message_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"to_jid": {
"name": "to_jid",
"type": "text",
"primaryKey": false,
"notNull": false
},
"from_jid": {
"name": "from_jid",
"type": "text",
"primaryKey": false,
"notNull": false
},
"body_encrypted": {
"name": "body_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_url": {
"name": "media_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_mime": {
"name": "media_mime",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_key": {
"name": "media_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"media_size": {
"name": "media_size",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "message_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"is_group": {
"name": "is_group",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"quoted_msg_id": {
"name": "quoted_msg_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"messages_session_id_idx": {
"name": "messages_session_id_idx",
"columns": [
{
"expression": "session_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_app_id_idx": {
"name": "messages_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_created_at_idx": {
"name": "messages_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"messages_whatsapp_msg_id_idx": {
"name": "messages_whatsapp_msg_id_idx",
"columns": [
{
"expression": "whatsapp_msg_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"messages_app_id_apps_id_fk": {
"name": "messages_app_id_apps_id_fk",
"tableFrom": "messages",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"messages_session_id_sessions_id_fk": {
"name": "messages_session_id_sessions_id_fk",
"tableFrom": "messages",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session_keys": {
"name": "session_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_type": {
"name": "key_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value_encrypted": {
"name": "value_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"session_keys_session_id_sessions_id_fk": {
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": [
"session_id",
"key_type",
"key_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "session_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'PENDING_QR'"
},
"phone_number": {
"name": "phone_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"credentials_encrypted": {
"name": "credentials_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"reconnect_count": {
"name": "reconnect_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"auto_read": {
"name": "auto_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_app_id_idx": {
"name": "sessions_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"sessions_status_idx": {
"name": "sessions_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_app_id_apps_id_fk": {
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"last_used_totp_token": {
"name": "last_used_totp_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": [
"user",
"api_key"
]
},
"public.message_direction": {
"name": "message_direction",
"schema": "public",
"values": [
"in",
"out"
]
},
"public.message_status": {
"name": "message_status",
"schema": "public",
"values": [
"queued",
"sent",
"delivered",
"read",
"failed"
]
},
"public.message_type": {
"name": "message_type",
"schema": "public",
"values": [
"text",
"image",
"video",
"document",
"audio"
]
},
"public.session_status": {
"name": "session_status",
"schema": "public",
"values": [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778765149216,
"tag": "0000_clean_wong",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1778768446063,
"tag": "0001_outgoing_joseph",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1778829335931,
"tag": "0002_orange_clint_barton",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778838712770,
"tag": "0003_clammy_azazel",
"breakpoints": true
}
]
}

View file

@ -1,20 +1,20 @@
// @ts-check
import { tanstackConfig } from '@tanstack/eslint-config'
import { tanstackConfig } from "@tanstack/eslint-config";
export default [
...tanstackConfig,
{
rules: {
'import/no-cycle': 'off',
'import/order': 'off',
'sort-imports': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/require-await': 'off',
'pnpm/json-enforce-catalog': 'off',
"import/no-cycle": "off",
"import/order": "off",
"sort-imports": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/require-await": "off",
"pnpm/json-enforce-catalog": "off",
},
},
{
ignores: ['eslint.config.js', 'prettier.config.js'],
ignores: ["eslint.config.js", "prettier.config.js", ".output/**"],
},
]
];

View file

@ -20,39 +20,58 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1047.0",
"@aws-sdk/s3-request-presigner": "^3.1047.0",
"@faker-js/faker": "^10.3.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/match-sorter-utils": "latest",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/react-table": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@whiskeysockets/baileys": "^7.0.0-rc11",
"argon2": "^0.44.0",
"bullmq": "^5.76.8",
"busboy": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"ioredis": "^5.10.1",
"iron-session": "^8.0.4",
"lucide-react": "^0.577.0",
"mime": "^4.1.0",
"nitro": "npm:nitro-nightly@latest",
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6"
"tw-animate-css": "^1.3.6",
"uuid": "^14.0.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/eslint-config": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/busboy": "^1.5.4",
"@types/node": "^22.10.2",
"@types/pg": "^8.15.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1",
"eslint": "^9.20.0",
@ -69,4 +88,4 @@
"lightningcss"
]
}
}
}

19
playwright.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30000,
retries: 0,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
headless: true,
channel: "chrome",
},
projects: [
{
name: "chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
],
});

21
scripts/seed-test-user.ts Normal file
View file

@ -0,0 +1,21 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { users } from "../src/db/schema.ts";
import argon2 from "argon2";
import { config } from "dotenv";
config({ path: [".env.local", ".env"] });
const db = drizzle(
`postgresql://${process.env.POSTGRES_USER ?? "postgres"}:${process.env.POSTGRES_PASSWORD ?? "postgres"}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? "5432"}/${process.env.POSTGRES_DB ?? "wahoo"}`,
{ schema: { users } },
);
const email = "test@wahoo.local";
const password = "TestPassword123!";
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
await db.insert(users).values({ email, passwordHash }).onConflictDoNothing();
console.log(`Seeded user: ${email} / ${password}`);
process.exit(0);

View file

@ -0,0 +1,225 @@
import { Link, useLocation, useRouter } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
LayoutGrid,
Zap,
ScrollText,
LogOut,
MessageSquare,
ChevronLeft,
Key,
} from "lucide-react";
import { appQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
export function Sidebar() {
const location = useLocation();
const router = useRouter();
// Detect app context: /apps/<uuid>[/*]
const appContextMatch = location.pathname.match(
/^\/apps\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$)/i,
);
const currentAppId = appContextMatch?.[1] ?? null;
const { data: currentApp } = useQuery({
...appQuery(currentAppId ?? ""),
enabled: !!currentAppId,
});
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
router.navigate({ to: "/login" });
}
return (
<aside className="w-[220px] h-screen flex flex-col bg-zinc-950 border-r border-zinc-900 flex-shrink-0">
{/* Logo */}
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-zinc-900">
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-900/30 flex-shrink-0">
<MessageSquare
size={14}
className="text-white fill-white"
strokeWidth={1.5}
/>
</div>
<span
className="text-white font-semibold text-[15px] tracking-tight"
style={{ fontFamily: "Fraunces, Georgia, serif" }}
>
wahoo
</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-3 overflow-y-auto">
{currentAppId ? (
<AppContextNav
appId={currentAppId}
appName={currentApp?.name}
pathname={location.pathname}
/>
) : (
<GlobalNav pathname={location.pathname} />
)}
</nav>
{/* Footer */}
<div className="px-2 pb-3 border-t border-zinc-900 pt-2">
<button
onClick={handleLogout}
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium text-zinc-500 hover:text-white hover:bg-zinc-900 w-full transition-colors duration-150 cursor-pointer"
>
<LogOut size={14} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
);
}
function GlobalNav({ pathname }: { pathname: string }) {
const items = [
{ to: "/apps" as const, label: "Apps", icon: LayoutGrid },
{ to: "/audit-log" as const, label: "Audit Log", icon: ScrollText },
];
return (
<div className="space-y-0.5">
{items.map(({ to, label, icon: Icon }) => {
const isActive =
to === "/apps"
? pathname === "/apps" || pathname === "/apps/"
: pathname === to;
return (
<Link
key={to}
to={to}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 w-full",
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Icon
size={14}
strokeWidth={isActive ? 2 : 1.75}
className={isActive ? "text-emerald-400" : ""}
/>
{label}
</Link>
);
})}
</div>
);
}
function AppContextNav({
appId,
appName,
pathname,
}: {
appId: string;
appName: string | undefined;
pathname: string;
}) {
const keysPath = `/apps/${appId}/`;
const sessionsPath = `/apps/${appId}/sessions`;
const isKeys =
pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` ||
(pathname.startsWith(`/apps/${appId}/`) &&
!pathname.startsWith(sessionsPath));
const isSessions = pathname.startsWith(sessionsPath);
const isAuditLog = pathname === "/audit-log";
return (
<div className="space-y-1">
{/* Back to Apps */}
<Link
to="/apps"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900 transition-colors duration-150 mb-1"
>
<ChevronLeft size={12} strokeWidth={2} />
All apps
</Link>
{/* App name header */}
<div className="px-3 py-1.5 mb-0.5">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center flex-shrink-0">
<LayoutGrid
size={10}
className="text-emerald-400"
strokeWidth={2}
/>
</div>
<span className="text-[12px] font-semibold text-zinc-300 truncate">
{appName ?? "…"}
</span>
</div>
</div>
{/* App-scoped items */}
<Link
to="/apps/$appId/"
params={{ appId }}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
isKeys
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Key
size={13}
strokeWidth={isKeys ? 2 : 1.75}
className={isKeys ? "text-emerald-400" : ""}
/>
API Keys
</Link>
<Link
to="/apps/$appId/sessions/"
params={{ appId }}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
isSessions
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Zap
size={13}
strokeWidth={isSessions ? 2 : 1.75}
className={isSessions ? "text-emerald-400" : ""}
/>
Sessions
</Link>
{/* Separator */}
<div className="border-t border-zinc-900 my-2 mx-1" />
{/* Global items still accessible */}
<Link
to="/audit-log"
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150",
isAuditLog
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<ScrollText
size={13}
strokeWidth={isAuditLog ? 2 : 1.75}
className={isAuditLog ? "text-emerald-400" : ""}
/>
Audit Log
</Link>
</div>
);
}

View file

@ -0,0 +1,49 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "#/lib/utils.ts";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,65 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "#/lib/utils.ts";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "#/lib/utils.ts";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts";
import { Button } from "#/components/ui/button.tsx";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "#/lib/utils.ts";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,22 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View file

@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View file

@ -0,0 +1,26 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "#/lib/utils.ts";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

114
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "#/lib/utils.ts";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View file

@ -1,5 +1,21 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from './schema.ts'
import * as schema from "./schema.ts";
export const db = drizzle(process.env.DATABASE_URL!, { schema })
function getDbUrl(): string {
const {
POSTGRES_USER,
POSTGRES_PASSWORD,
POSTGRES_HOST,
POSTGRES_PORT,
POSTGRES_DB,
} = process.env;
const user = POSTGRES_USER ?? "wahoo";
const pass = POSTGRES_PASSWORD ?? "wahoo";
const host = POSTGRES_HOST ?? "localhost";
const port = POSTGRES_PORT ?? "5432";
const db = POSTGRES_DB ?? "wahoo";
return `postgresql://${user}:${encodeURIComponent(pass)}@${host}:${port}/${db}`;
}
export const db = drizzle(getDbUrl(), { schema });

View file

@ -1,7 +1,211 @@
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
unique,
uuid,
bigint,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const todos = pgTable('todos', {
id: serial().primaryKey(),
title: text().notNull(),
createdAt: timestamp('created_at').defaultNow(),
})
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
totpSecret: text("totp_secret"),
totpEnabled: boolean("totp_enabled").notNull().default(false),
lastUsedTotpToken: text("last_used_totp_token"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const apps = pgTable("apps", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
ownerUserId: uuid("owner_user_id")
.notNull()
.references(() => users.id),
dekEncrypted: text("dek_encrypted").notNull(),
maxSessions: integer("max_sessions").notNull().default(10),
deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const apiKeys = pgTable(
"api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
label: text("label").notNull().default(""),
revokedAt: timestamp("revoked_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [index("api_keys_app_id_idx").on(t.appId)],
);
export const usersRelations = relations(users, ({ many }) => ({
apps: many(apps),
}));
export const appsRelations = relations(apps, ({ one, many }) => ({
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
apiKeys: many(apiKeys),
sessions: many(sessions),
messages: many(messages),
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
}));
export const sessionStatusEnum = pgEnum("session_status", [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD",
]);
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
status: sessionStatusEnum("status").notNull().default("PENDING_QR"),
phoneNumber: text("phone_number"),
displayName: text("display_name"),
credentialsEncrypted: text("credentials_encrypted"),
reconnectCount: integer("reconnect_count").notNull().default(0),
autoRead: boolean("auto_read").notNull().default(false),
lastSeen: timestamp("last_seen"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [
index("sessions_app_id_idx").on(t.appId),
index("sessions_status_idx").on(t.status),
],
);
export const sessionKeys = pgTable(
"session_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => sessions.id, { onDelete: "cascade" }),
keyType: text("key_type").notNull(),
keyId: text("key_id").notNull(),
valueEncrypted: text("value_encrypted").notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => [unique("session_keys_unique").on(t.sessionId, t.keyType, t.keyId)],
);
export const sessionsRelations = relations(sessions, ({ one, many }) => ({
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
keys: many(sessionKeys),
messages: many(messages),
}));
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
session: one(sessions, {
fields: [sessionKeys.sessionId],
references: [sessions.id],
}),
}));
export const messageTypeEnum = pgEnum("message_type", [
"text",
"image",
"video",
"document",
"audio",
]);
export const messageStatusEnum = pgEnum("message_status", [
"queued",
"sent",
"delivered",
"read",
"failed",
]);
export const messageDirectionEnum = pgEnum("message_direction", ["in", "out"]);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
sessionId: uuid("session_id")
.notNull()
.references(() => sessions.id, { onDelete: "cascade" }),
whatsappMsgId: text("whatsapp_msg_id").notNull(),
direction: messageDirectionEnum("direction").notNull(),
type: messageTypeEnum("type").notNull(),
toJid: text("to_jid"),
fromJid: text("from_jid"),
bodyEncrypted: text("body_encrypted"),
mediaUrl: text("media_url"),
mediaMime: text("media_mime"),
mediaKey: text("media_key"),
mediaSize: bigint("media_size", { mode: "number" }),
status: messageStatusEnum("status").notNull().default("queued"),
isGroup: boolean("is_group").notNull().default(false),
quotedMsgId: text("quoted_msg_id"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => [
index("messages_session_id_idx").on(t.sessionId),
index("messages_app_id_idx").on(t.appId),
index("messages_created_at_idx").on(t.createdAt),
index("messages_whatsapp_msg_id_idx").on(t.whatsappMsgId),
],
);
export const messagesRelations = relations(messages, ({ one }) => ({
app: one(apps, { fields: [messages.appId], references: [apps.id] }),
session: one(sessions, {
fields: [messages.sessionId],
references: [sessions.id],
}),
}));
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
actorType: actorTypeEnum("actor_type").notNull(),
actorId: uuid("actor_id").notNull(),
action: text("action").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id"),
ip: text("ip"),
userAgent: text("user_agent"),
diffBefore: jsonb("diff_before"),
diffAfter: jsonb("diff_after"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => [
index("audit_log_actor_idx").on(t.actorType, t.actorId),
index("audit_log_action_idx").on(t.action),
index("audit_log_created_at_idx").on(t.createdAt),
],
);

View file

@ -0,0 +1,89 @@
import { and, eq } from "drizzle-orm";
import { db } from "#/db/index";
import { apiKeys } from "#/db/schema";
import { generateApiKey, hashApiKey } from "#/lib/api-keys";
import { writeAudit } from "#/lib/audit";
export async function listAppApiKeys(appId: string) {
const keys = await db.query.apiKeys.findMany({
where: eq(apiKeys.appId, appId),
columns: {
id: true,
keyPrefix: true,
label: true,
revokedAt: true,
createdAt: true,
},
});
return keys.map((k) => ({ ...k, display: `••••${k.keyPrefix}` }));
}
export async function createAppApiKey(
appId: string,
userId: string,
label?: string,
env?: "live" | "test",
audit?: { ip?: string; userAgent?: string },
) {
const { key, prefix } = generateApiKey(env ?? "live");
const keyHash = await hashApiKey(key);
const [created] = await db
.insert(apiKeys)
.values({
appId,
keyHash,
keyPrefix: prefix,
label: label ?? "",
})
.returning({
id: apiKeys.id,
keyPrefix: apiKeys.keyPrefix,
label: apiKeys.label,
createdAt: apiKeys.createdAt,
});
await writeAudit({
actorType: "user",
actorId: userId,
action: "api_key.create",
resourceType: "api_key",
resourceId: created.id,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffAfter: { label: created.label, appId },
});
return { ...created, key };
}
export async function revokeAppApiKey(
keyId: string,
appId: string,
userId: string,
audit?: { ip?: string; userAgent?: string },
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const key = await db.query.apiKeys.findFirst({
where: and(eq(apiKeys.id, keyId), eq(apiKeys.appId, appId)),
});
if (!key) return { ok: false, error: "Key not found", status: 404 };
if (key.revokedAt)
return { ok: false, error: "Key already revoked", status: 409 };
await db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(eq(apiKeys.id, keyId));
await writeAudit({
actorType: "user",
actorId: userId,
action: "api_key.revoke",
resourceType: "api_key",
resourceId: keyId,
ip: audit?.ip,
userAgent: audit?.userAgent,
});
return { ok: true };
}

132
src/lib/actions/apps.ts Normal file
View file

@ -0,0 +1,132 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "#/db/index";
import { apps, apiKeys } from "#/db/schema";
import { generateDek, wrapDek } from "#/lib/crypto";
import { writeAudit } from "#/lib/audit";
type App = typeof apps.$inferSelect;
export async function listUserApps(userId: string) {
return db.query.apps.findMany({
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
columns: { id: true, name: true, maxSessions: true, createdAt: true },
});
}
export async function findUserApp(
appId: string,
userId: string,
): Promise<App | null> {
return (
(await db.query.apps.findFirst({
where: and(
eq(apps.id, appId),
eq(apps.ownerUserId, userId),
isNull(apps.deletedAt),
),
})) ?? null
);
}
export async function createUserApp(
userId: string,
name: string,
maxSessions?: number,
audit?: { ip?: string; userAgent?: string },
) {
const dek = generateDek();
const dekEncrypted = wrapDek(dek);
const [app] = await db
.insert(apps)
.values({
name: name.trim(),
ownerUserId: userId,
dekEncrypted,
maxSessions: maxSessions ?? 10,
})
.returning({ id: apps.id, name: apps.name, createdAt: apps.createdAt });
await writeAudit({
actorType: "user",
actorId: userId,
action: "app.create",
resourceType: "app",
resourceId: app.id,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffAfter: { name: app.name },
});
return app;
}
export async function updateUserApp(
appId: string,
userId: string,
updates: { name?: string; maxSessions?: number },
audit?: { ip?: string; userAgent?: string },
): Promise<typeof apps.$inferSelect | null> {
const app = await findUserApp(appId, userId);
if (!app) return null;
const dbUpdates: Partial<typeof apps.$inferInsert> = {};
if (updates.name !== undefined) dbUpdates.name = updates.name.trim();
if (updates.maxSessions !== undefined)
dbUpdates.maxSessions = updates.maxSessions;
const [updated] = await db
.update(apps)
.set(dbUpdates)
.where(eq(apps.id, appId))
.returning({
id: apps.id,
name: apps.name,
maxSessions: apps.maxSessions,
});
await writeAudit({
actorType: "user",
actorId: userId,
action: "app.update",
resourceType: "app",
resourceId: appId,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffBefore: { name: app.name, maxSessions: app.maxSessions },
diffAfter: dbUpdates,
});
return updated as App;
}
export async function deleteUserApp(
appId: string,
userId: string,
audit?: { ip?: string; userAgent?: string },
): Promise<boolean> {
const app = await findUserApp(appId, userId);
if (!app) return false;
const now = new Date();
await db.transaction(async (tx) => {
await tx.update(apps).set({ deletedAt: now }).where(eq(apps.id, appId));
await tx
.update(apiKeys)
.set({ revokedAt: now })
.where(and(eq(apiKeys.appId, appId), isNull(apiKeys.revokedAt)));
});
await writeAudit({
actorType: "user",
actorId: userId,
action: "app.delete",
resourceType: "app",
resourceId: appId,
ip: audit?.ip,
userAgent: audit?.userAgent,
diffBefore: { name: app.name },
});
return true;
}

View file

@ -0,0 +1,44 @@
import { and, desc, eq, gte, lte } from "drizzle-orm";
import { db } from "#/db/index";
import { auditLog } from "#/db/schema";
interface AuditLogFilters {
page?: number;
limit?: number;
action?: string | null;
resourceType?: string | null;
since?: string | null;
until?: string | null;
}
export async function queryAuditLog(
userId: string,
filters: AuditLogFilters = {},
) {
const page = Math.max(1, filters.page ?? 1);
const limit = Math.min(100, Math.max(1, filters.limit ?? 50));
const conditions = [eq(auditLog.actorId, userId)];
if (filters.action) conditions.push(eq(auditLog.action, filters.action));
if (filters.resourceType)
conditions.push(eq(auditLog.resourceType, filters.resourceType));
if (filters.since) {
const d = new Date(filters.since);
if (isNaN(d.getTime())) return { data: [], page, limit };
conditions.push(gte(auditLog.createdAt, d));
}
if (filters.until) {
const d = new Date(filters.until);
if (isNaN(d.getTime())) return { data: [], page, limit };
conditions.push(lte(auditLog.createdAt, d));
}
const rows = await db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset: (page - 1) * limit,
});
return { data: rows, page, limit };
}

90
src/lib/actions/auth.ts Normal file
View file

@ -0,0 +1,90 @@
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "#/db/index";
import { users } from "#/db/schema";
import { generateTotpSecret, getTotpUri, verifyTotp } from "#/lib/totp";
import {
decryptWithMasterKey,
encryptWithMasterKey,
} from "#/lib/crypto";
export async function findUserByEmail(email: string) {
return (
(await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
})) ?? null
);
}
export async function findUserById(userId: string) {
return (
(await db.query.users.findFirst({ where: eq(users.id, userId) })) ?? null
);
}
export async function enrollTotpSecret(
userId: string,
): Promise<
| { ok: true; secret: string; uri: string }
| { ok: false; error: string; status: number }
> {
const user = await findUserById(userId);
if (!user) return { ok: false, error: "User not found", status: 404 };
if (user.totpEnabled)
return { ok: false, error: "TOTP already enabled", status: 409 };
const secret = generateTotpSecret();
await db
.update(users)
.set({ totpSecret: encryptWithMasterKey(secret) })
.where(eq(users.id, userId));
return { ok: true, secret, uri: getTotpUri(user.email, secret) };
}
export async function confirmTotpEnrollment(
userId: string,
token: string,
): Promise<{ ok: true } | { ok: false; error: string; status: number }> {
const user = await findUserById(userId);
if (!user?.totpSecret)
return { ok: false, error: "Enroll first", status: 400 };
if (user.totpEnabled)
return { ok: false, error: "TOTP already enabled", status: 409 };
const decryptedSecret = decryptWithMasterKey(user.totpSecret);
if (!verifyTotp(decryptedSecret, token))
return { ok: false, error: "Invalid token", status: 401 };
const tokenHash = createHash("sha256").update(token).digest("hex");
await db
.update(users)
.set({ totpEnabled: true, lastUsedTotpToken: tokenHash })
.where(eq(users.id, userId));
return { ok: true };
}
export async function verifyTotpToken(
user: typeof users.$inferSelect,
token: string,
): Promise<boolean> {
if (!user.totpSecret) return false;
const secret = decryptWithMasterKey(user.totpSecret);
if (!verifyTotp(secret, token)) return false;
const tokenHash = createHash("sha256").update(token).digest("hex");
if (user.lastUsedTotpToken === tokenHash) return false;
await db
.update(users)
.set({ lastUsedTotpToken: tokenHash })
.where(eq(users.id, user.id));
return true;
}
export async function disableTotp(userId: string): Promise<void> {
await db
.update(users)
.set({ totpEnabled: false, totpSecret: null })
.where(eq(users.id, userId));
}

125
src/lib/actions/messages.ts Normal file
View file

@ -0,0 +1,125 @@
import { and, desc, eq, gt, lte } from "drizzle-orm";
import { db } from "#/db/index";
import { messages } from "#/db/schema";
import { encryptColumn, decryptColumn } from "#/lib/crypto";
import { getStorageDriver } from "#/lib/storage";
type Message = typeof messages.$inferSelect;
type MessageInsert = typeof messages.$inferInsert;
const SIGNED_URL_TTL = Number(process.env.MEDIA_SIGNED_URL_TTL_SECONDS ?? 3600);
const ENCRYPT_BODY = process.env.ENCRYPT_MESSAGE_BODY === "true";
export async function createMessage(
data: Omit<MessageInsert, "id" | "createdAt" | "updatedAt">,
dek: Buffer,
): Promise<Message> {
const bodyEncrypted =
ENCRYPT_BODY && data.bodyEncrypted
? encryptColumn(data.bodyEncrypted, dek)
: data.bodyEncrypted;
const [msg] = await db
.insert(messages)
.values({ ...data, bodyEncrypted })
.returning();
return msg;
}
export async function updateMessageStatus(
whatsappMsgId: string,
sessionId: string,
status: Message["status"],
): Promise<void> {
await db
.update(messages)
.set({ status, updatedAt: new Date() })
.where(
and(
eq(messages.whatsappMsgId, whatsappMsgId),
eq(messages.sessionId, sessionId),
),
);
}
export async function findMessageByWhatsappId(
whatsappMsgId: string,
sessionId: string,
): Promise<Message | null> {
return (
(await db.query.messages.findFirst({
where: and(
eq(messages.whatsappMsgId, whatsappMsgId),
eq(messages.sessionId, sessionId),
),
})) ?? null
);
}
export async function listMessages(
sessionId: string,
opts: {
since?: Date;
limit?: number;
direction?: "in" | "out";
},
dek: Buffer,
) {
const limit = Math.min(opts.limit ?? 50, 200);
const conditions = [eq(messages.sessionId, sessionId)];
if (opts.since) conditions.push(gt(messages.createdAt, opts.since));
if (opts.direction) conditions.push(eq(messages.direction, opts.direction));
const rows = await db.query.messages.findMany({
where: and(...conditions),
orderBy: [desc(messages.createdAt)],
limit,
});
const driver = await getStorageDriver();
return Promise.all(
rows.map(async (msg) => {
const body =
ENCRYPT_BODY && msg.bodyEncrypted
? decryptColumn(msg.bodyEncrypted, dek)
: msg.bodyEncrypted;
const mediaUrl = msg.mediaKey
? await driver.signedUrl(msg.mediaKey, SIGNED_URL_TTL)
: msg.mediaUrl;
return { ...msg, bodyEncrypted: undefined, body, mediaUrl };
}),
);
}
export async function findMessage(
id: string,
sessionId: string,
): Promise<Message | null> {
return (
(await db.query.messages.findFirst({
where: and(eq(messages.id, id), eq(messages.sessionId, sessionId)),
})) ?? null
);
}
export async function deleteOldMedia(olderThanDays: number): Promise<number> {
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
const driver = await getStorageDriver();
const old = await db.query.messages.findMany({
where: and(lte(messages.createdAt, cutoff)),
columns: { id: true, mediaKey: true },
});
let deleted = 0;
for (const msg of old) {
if (!msg.mediaKey) continue;
await driver.delete(msg.mediaKey).catch(() => {});
await db
.update(messages)
.set({ mediaUrl: null, mediaKey: null, updatedAt: new Date() })
.where(eq(messages.id, msg.id));
deleted++;
}
return deleted;
}

196
src/lib/actions/sessions.ts Normal file
View file

@ -0,0 +1,196 @@
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { db } from "#/db/index";
import { apps, sessions, sessionKeys } from "#/db/schema";
import { startSession, stopSession, emitWebhook } from "#/lib/baileys";
type Session = typeof sessions.$inferSelect;
type SessionStatus = Session["status"];
type SessionScope = { appId: string } | { userId: string };
const ACTIVE_STATUSES = [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
] as const;
export async function findSession(
sessionId: string,
scope: SessionScope,
): Promise<Session | null> {
if ("appId" in scope) {
return (
(await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, scope.appId)),
})) ?? null
);
}
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, scope.userId), isNull(apps.deletedAt)),
columns: { id: true },
});
if (userApps.length === 0) return null;
const appIds = userApps.map((a) => a.id);
return (
(await db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), inArray(sessions.appId, appIds)),
})) ?? null
);
}
export async function listUserSessions(userId: string) {
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, userId), isNull(apps.deletedAt)),
columns: { id: true, name: true },
});
if (userApps.length === 0) return [];
const appIds = userApps.map((a) => a.id);
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]));
const rows = await db.query.sessions.findMany({
where: (s, { inArray: _inArray }) => _inArray(s.appId, appIds),
columns: {
id: true,
appId: true,
status: true,
reconnectCount: true,
lastSeen: true,
createdAt: true,
},
orderBy: (s, { desc }) => [desc(s.createdAt)],
});
return rows.map((r) => ({ ...r, appName: appNameMap[r.appId] || "" }));
}
export async function listAppSessions(appId: string) {
return db.query.sessions.findMany({
where: eq(sessions.appId, appId),
columns: {
id: true,
status: true,
lastSeen: true,
createdAt: true,
reconnectCount: true,
},
});
}
export async function createSession(
appId: string,
via: "qr" | "pairing",
ownerUserId?: string,
): Promise<
| { ok: true; session: { id: string; status: string; createdAt: Date } }
| { ok: false; error: string; status: number }
> {
const appWhere = ownerUserId
? and(
eq(apps.id, appId),
eq(apps.ownerUserId, ownerUserId),
isNull(apps.deletedAt),
)
: and(eq(apps.id, appId), isNull(apps.deletedAt));
const app = await db.query.apps.findFirst({
where: appWhere,
columns: { id: true },
});
if (!app) return { ok: false, error: "App not found", status: 404 };
const initialStatus: SessionStatus =
via === "pairing" ? "PENDING_PAIRING" : "PENDING_QR";
type TxResult =
| { limited: true; max: number }
| { limited: false; session: { id: string; status: string; createdAt: Date } };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
// Lock app row to serialize concurrent createSession calls for the same app
const lock = await tx.execute(
sql`SELECT max_sessions FROM apps WHERE id = ${appId} FOR UPDATE`,
);
const maxSessions = Number(
(lock.rows[0] as { max_sessions: unknown }).max_sessions,
);
const cnt = await tx.execute(
sql`SELECT COUNT(*)::int AS n FROM sessions WHERE app_id = ${appId} AND status = ANY(ARRAY['PENDING_QR','PENDING_PAIRING','CONNECTING','CONNECTED','DISCONNECTED']::session_status[])`,
);
const activeCount = Number((cnt.rows[0] as { n: unknown }).n);
if (activeCount >= maxSessions) return { limited: true, max: maxSessions };
const [session] = await tx
.insert(sessions)
.values({ appId, status: initialStatus })
.returning({
id: sessions.id,
status: sessions.status,
createdAt: sessions.createdAt,
});
return { limited: false, session };
});
if (txResult.limited) {
return {
ok: false,
error: `Session limit reached (max ${txResult.max})`,
status: 429,
};
}
if (via === "qr") startSession(txResult.session.id, appId).catch(console.error);
return { ok: true, session: txResult.session };
}
export async function setSessionStatus(
sessionId: string,
status: SessionStatus,
): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
}
export async function disconnectSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await setSessionStatus(sessionId, "DISCONNECTED");
emitWebhook(appId, "session.status", { sessionId, status: "DISCONNECTED" });
}
export async function deleteSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await db.delete(sessions).where(eq(sessions.id, sessionId));
emitWebhook(appId, "session.deleted", { sessionId });
}
export async function logoutAppSession(
sessionId: string,
appId: string,
): Promise<void> {
await stopSession(sessionId);
await db.transaction(async (tx) => {
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
await tx
.update(sessions)
.set({
status: "LOGGED_OUT",
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
})
.where(eq(sessions.id, sessionId));
});
emitWebhook(appId, "session.deleted", { sessionId });
}

36
src/lib/api-key-auth.ts Normal file
View file

@ -0,0 +1,36 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "#/db/index";
import { apiKeys } from "#/db/schema";
import { verifyApiKey } from "./api-keys";
import { jsonError } from "./session-api";
export interface AuthedRequest {
appId: string;
keyId: string;
}
export async function authenticateApiKey(
request: Request,
): Promise<AuthedRequest | Response> {
const auth = request.headers.get("authorization");
if (!auth?.startsWith("Bearer "))
return jsonError("Missing Authorization header", 401);
const key = auth.slice(7).trim();
if (!key.startsWith("wak_")) return jsonError("Invalid API key format", 401);
const prefix = key.slice(-4);
const candidates = await db.query.apiKeys.findMany({
where: and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.revokedAt)),
with: { app: { columns: { id: true, deletedAt: true } } },
});
for (const candidate of candidates) {
if (candidate.app.deletedAt) continue;
const valid = await verifyApiKey(key, candidate.keyHash);
if (valid) return { appId: candidate.appId, keyId: candidate.id };
}
return jsonError("Invalid or revoked API key", 401);
}

29
src/lib/api-keys.ts Normal file
View file

@ -0,0 +1,29 @@
import { randomBytes } from "node:crypto";
import argon2 from "argon2";
export type ApiKeyEnv = "live" | "test";
export function generateApiKey(env: ApiKeyEnv = "live"): {
key: string;
prefix: string;
} {
const rand = randomBytes(24).toString("base64url");
const key = `wak_${env}_${rand}`;
const prefix = key.slice(-4);
return { key, prefix };
}
export async function hashApiKey(key: string): Promise<string> {
return argon2.hash(key, { type: argon2.argon2id });
}
export async function verifyApiKey(
key: string,
hash: string,
): Promise<boolean> {
try {
return await argon2.verify(hash, key);
} catch {
return false;
}
}

28
src/lib/audit.ts Normal file
View file

@ -0,0 +1,28 @@
import { db } from "#/db/index";
import { auditLog } from "#/db/schema";
interface AuditEntry {
actorType: "user" | "api_key";
actorId: string;
action: string;
resourceType: string;
resourceId?: string;
ip?: string;
userAgent?: string;
diffBefore?: unknown;
diffAfter?: unknown;
}
export async function writeAudit(entry: AuditEntry): Promise<void> {
await db.insert(auditLog).values({
actorType: entry.actorType,
actorId: entry.actorId,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId,
ip: entry.ip,
userAgent: entry.userAgent,
diffBefore: entry.diffBefore ?? null,
diffAfter: entry.diffAfter ?? null,
});
}

View file

@ -0,0 +1,130 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import {
BufferJSON,
initAuthCreds,
makeCacheableSignalKeyStore,
} from "@whiskeysockets/baileys";
import type {
AuthenticationState,
SignalDataSet,
SignalDataTypeMap,
SignalKeyStore,
} from "@whiskeysockets/baileys";
import { db } from "#/db/index";
import { sessionKeys, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn } from "#/lib/crypto";
export async function usePostgresAuthState(
sessionId: string,
appDek: Buffer,
): Promise<{ state: AuthenticationState; saveCreds: () => Promise<void> }> {
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { credentialsEncrypted: true },
});
let creds = initAuthCreds();
if (row?.credentialsEncrypted) {
try {
const json = decryptColumn(row.credentialsEncrypted, appDek);
creds = JSON.parse(json, BufferJSON.reviver);
} catch {
creds = initAuthCreds();
}
}
const store: SignalKeyStore = {
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
if (ids.length === 0) return {};
const rows = await db.query.sessionKeys.findMany({
where: and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
});
const result: { [id: string]: SignalDataTypeMap[T] } = {};
for (const r of rows) {
try {
const json = decryptColumn(r.valueEncrypted, appDek);
result[r.keyId] = JSON.parse(json, BufferJSON.reviver);
} catch {
// skip corrupt key
}
}
return result;
},
async set(data: SignalDataSet) {
type Row = {
sessionId: string;
keyType: string;
keyId: string;
valueEncrypted: string;
};
const toDelete = new Map<string, string[]>();
const toUpsert: Row[] = [];
for (const [type, typeData] of Object.entries(data)) {
for (const [id, value] of Object.entries(typeData)) {
if (value == null) {
if (!toDelete.has(type)) toDelete.set(type, []);
toDelete.get(type)!.push(id);
} else {
const serialized = JSON.stringify(value, BufferJSON.replacer);
toUpsert.push({
sessionId,
keyType: type,
keyId: id,
valueEncrypted: encryptColumn(serialized, appDek),
});
}
}
}
for (const [type, ids] of toDelete) {
await db.delete(sessionKeys).where(
and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
);
}
if (toUpsert.length > 0) {
await db
.insert(sessionKeys)
.values(toUpsert)
.onConflictDoUpdate({
target: [
sessionKeys.sessionId,
sessionKeys.keyType,
sessionKeys.keyId,
],
set: {
valueEncrypted: sql`EXCLUDED.value_encrypted`,
updatedAt: sql`NOW()`,
},
});
}
},
async clear() {
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId));
},
};
const keys = makeCacheableSignalKeyStore(store);
async function saveCreds() {
const json = JSON.stringify(creds, BufferJSON.replacer);
const encrypted = encryptColumn(json, appDek);
await db
.update(sessions)
.set({ credentialsEncrypted: encrypted })
.where(eq(sessions.id, sessionId));
}
return { state: { creds, keys }, saveCreds };
}

12
src/lib/baileys/index.ts Normal file
View file

@ -0,0 +1,12 @@
export { usePostgresAuthState } from "./auth-state";
export {
startSession,
stopSession,
getQrCode,
getSocket,
isActive,
initialize,
getDecryptedField,
getAppDekForSession,
} from "./session-manager";
export { emitWebhook } from "./webhook";

View file

@ -0,0 +1,493 @@
import { eq, inArray } from "drizzle-orm";
import {
Browsers,
DisconnectReason,
downloadMediaMessage,
jidDecode,
makeWASocket,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
import type { WASocket } from "@whiskeysockets/baileys";
import { db } from "#/db/index";
import { apps, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
import { createMessage, updateMessageStatus } from "#/lib/actions/messages";
import { getStorageDriver } from "#/lib/storage";
import { usePostgresAuthState } from "./auth-state";
import { emitWebhook } from "./webhook";
import { v4 as uuidv4 } from "uuid";
import mime from "mime";
type SessionStatus = (typeof sessions.$inferSelect)["status"];
interface SessionEntry {
socket: WASocket;
qrCode: string | null;
appId: string;
dek: Buffer;
}
interface ReconnectEntry {
start: Date;
count: number;
timer: ReturnType<typeof setTimeout> | null;
}
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
// Store on global to survive Vite SSR module isolation / HMR reloads
declare global {
var __wahooActiveSessions: Map<string, SessionEntry> | undefined;
var __wahooReconnecting: Map<string, ReconnectEntry> | undefined;
var __wahooQrTimers: Map<string, ReturnType<typeof setTimeout>> | undefined;
}
globalThis.__wahooActiveSessions ??= new Map<string, SessionEntry>();
globalThis.__wahooReconnecting ??= new Map<string, ReconnectEntry>();
globalThis.__wahooQrTimers ??= new Map<string, ReturnType<typeof setTimeout>>();
const active = globalThis.__wahooActiveSessions;
const reconnecting = globalThis.__wahooReconnecting;
const qrTimers = globalThis.__wahooQrTimers;
async function getAppDek(appId: string): Promise<Buffer> {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
});
if (!app) throw new Error(`App ${appId} not found`);
return unwrapDek(app.dekEncrypted);
}
async function setStatus(
sessionId: string,
status: SessionStatus,
): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId));
}
async function scheduleReconnect(
sessionId: string,
appId: string,
): Promise<void> {
let rec = reconnecting.get(sessionId);
if (rec) {
const elapsed = Date.now() - rec.start.getTime();
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
reconnecting.delete(sessionId);
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
return;
}
} else {
rec = { start: new Date(), count: 0, timer: null };
reconnecting.set(sessionId, rec);
}
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000);
rec.count++;
await db
.update(sessions)
.set({ reconnectCount: rec.count })
.where(eq(sessions.id, sessionId));
rec.timer = setTimeout(() => {
const r = reconnecting.get(sessionId);
if (r) r.timer = null;
startSession(sessionId, appId).catch(console.error);
}, delay);
}
export async function startSession(
sessionId: string,
appId: string,
): Promise<void> {
if (active.has(sessionId)) return;
const dek = await getAppDek(appId);
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek);
const sock = makeWASocket({
auth: state,
browser: Browsers.ubuntu("Chrome"),
printQRInTerminal: false,
syncFullHistory: false,
});
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek };
active.set(sessionId, entry);
sock.ev.on("creds.update", async (patch) => {
Object.assign(state.creds, patch);
await saveCreds().catch(console.error);
});
sock.ev.on("messages.upsert", async ({ messages: msgs, type }) => {
if (type !== "notify") return;
const driver = await getStorageDriver();
const SIGNED_URL_TTL = Number(
process.env.MEDIA_SIGNED_URL_TTL_SECONDS ?? 3600,
);
for (const msg of msgs) {
const isInbound = !msg.key.fromMe;
const jid = msg.key.remoteJid ?? "";
const whatsappMsgId = msg.key.id ?? uuidv4();
const content = normalizeMessageContent(msg.message ?? null);
let msgType: "text" | "image" | "video" | "document" | "audio" = "text";
let body: string | undefined;
let mediaUrl: string | undefined;
let mediaKey: string | undefined;
let mediaMime: string | undefined;
let mediaSize: number | undefined;
if (content?.conversation || content?.extendedTextMessage) {
msgType = "text";
body = content.conversation ?? content.extendedTextMessage?.text ?? "";
} else if (content?.imageMessage) {
msgType = "image";
body = content.imageMessage.caption ?? undefined;
mediaMime = content.imageMessage.mimetype ?? "image/jpeg";
} else if (content?.videoMessage) {
msgType = "video";
body = content.videoMessage.caption ?? undefined;
mediaMime = content.videoMessage.mimetype ?? "video/mp4";
} else if (content?.documentMessage) {
msgType = "document";
body = content.documentMessage.fileName ?? undefined;
mediaMime =
content.documentMessage.mimetype ?? "application/octet-stream";
} else if (content?.audioMessage) {
msgType = "audio";
mediaMime = content.audioMessage.mimetype ?? "audio/ogg";
} else {
continue;
}
// Download inbound media and store
if (isInbound && msgType !== "text") {
try {
const buffer = await downloadMediaMessage(msg, "buffer", {});
if (buffer instanceof Buffer) {
const ext = mediaMime
? mime.getExtension(mediaMime)
? `.${mime.getExtension(mediaMime)}`
: ""
: "";
const storageKey = `media/${appId}/${sessionId}/${whatsappMsgId}${ext}`;
await driver.upload(
storageKey,
buffer,
mediaMime ?? "application/octet-stream",
);
mediaKey = storageKey;
mediaUrl = await driver.signedUrl(storageKey, SIGNED_URL_TTL);
mediaSize = buffer.length;
}
} catch (err) {
console.error("[session-manager] media download failed", err);
}
}
try {
const saved = await createMessage(
{
appId,
sessionId,
whatsappMsgId,
direction: isInbound ? "in" : "out",
type: msgType,
toJid: isInbound ? undefined : jid,
fromJid: isInbound ? jid : undefined,
bodyEncrypted: body,
mediaUrl: mediaUrl ?? null,
mediaKey: mediaKey ?? null,
mediaMime: mediaMime ?? null,
mediaSize: mediaSize ?? null,
status: isInbound ? "delivered" : "sent",
isGroup: jid.endsWith("@g.us"),
},
dek,
);
// Auto-read
const sessionRow = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { autoRead: true },
});
if (isInbound && sessionRow?.autoRead) {
await sock.readMessages([msg.key]).catch(() => {});
}
emitWebhook(appId, isInbound ? "message.received" : "message.sent", {
messageId: saved.id,
sessionId,
whatsappMsgId,
type: msgType,
direction: saved.direction,
from: saved.fromJid,
to: saved.toJid,
body,
mediaUrl,
mediaMime,
isGroup: saved.isGroup,
});
} catch (err) {
console.error("[session-manager] message store failed", err);
}
}
});
sock.ev.on("message-receipt.update", async (updates) => {
for (const update of updates) {
const whatsappMsgId = update.key.id ?? "";
const receipts = update.receipt;
let newStatus: "delivered" | "read" | null = null;
if (receipts.readTimestamp) newStatus = "read";
else if (receipts.receiptTimestamp) newStatus = "delivered";
if (!newStatus) continue;
await updateMessageStatus(whatsappMsgId, sessionId, newStatus).catch(
console.error,
);
const eventName =
newStatus === "read" ? "message.read" : "message.delivered";
emitWebhook(appId, eventName, {
sessionId,
whatsappMsgId,
status: newStatus,
});
}
});
sock.ev.on("presence.update", ({ id: contactJid, presences }) => {
for (const [participant, presence] of Object.entries(presences)) {
const apiPresence = mapBaileysPresence(presence.lastKnownPresence);
emitWebhook(appId, "presence.update", {
sessionId,
jid: contactJid,
participant,
presence: apiPresence,
lastSeen: presence.lastSeen,
});
}
});
sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
entry.qrCode = qr;
// Reset the 5-min expiry timer on each new QR rotation
const existing = qrTimers.get(sessionId);
if (existing) clearTimeout(existing);
qrTimers.set(
sessionId,
setTimeout(async () => {
qrTimers.delete(sessionId);
const e = active.get(sessionId);
if (!e) return;
// Only expire if still waiting for QR
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { status: true },
});
if (row?.status === "PENDING_QR") {
active.delete(sessionId);
try {
e.socket.end(undefined);
} catch {
/* ignore */
}
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
}
}, QR_TIMEOUT_MS),
);
await setStatus(sessionId, "PENDING_QR");
emitWebhook(appId, "session.status", { sessionId, status: "PENDING_QR" });
}
if (connection === "connecting") {
await setStatus(sessionId, "CONNECTING");
}
if (connection === "open") {
entry.qrCode = null;
reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const meId = state.creds.me?.id;
const mePhone = meId ? jidDecode(meId)?.user : undefined;
const meName = state.creds.me?.name;
await db
.update(sessions)
.set({
status: "CONNECTED",
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
displayName: meName ? encryptColumn(meName, dek) : undefined,
lastSeen: new Date(),
reconnectCount: 0,
})
.where(eq(sessions.id, sessionId));
emitWebhook(appId, "session.status", { sessionId, status: "CONNECTED" });
}
if (connection === "close") {
active.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const code = (
lastDisconnect?.error as
| { output?: { statusCode?: number } }
| undefined
)?.output?.statusCode;
if (
code === DisconnectReason.loggedOut ||
code === DisconnectReason.forbidden ||
code === DisconnectReason.connectionReplaced
) {
reconnecting.delete(sessionId);
await setStatus(sessionId, "LOGGED_OUT");
emitWebhook(appId, "session.status", {
sessionId,
status: "LOGGED_OUT",
});
return;
}
// Never authenticated (QR expired/exhausted) — don't reconnect
if (!state.creds.me) {
reconnecting.delete(sessionId);
await setStatus(sessionId, "DEAD");
emitWebhook(appId, "session.status", { sessionId, status: "DEAD" });
return;
}
await setStatus(sessionId, "DISCONNECTED");
emitWebhook(appId, "session.status", {
sessionId,
status: "DISCONNECTED",
});
await scheduleReconnect(sessionId, appId);
}
});
}
export async function stopSession(sessionId: string): Promise<void> {
const rec = reconnecting.get(sessionId);
if (rec?.timer) clearTimeout(rec.timer);
reconnecting.delete(sessionId);
const qt = qrTimers.get(sessionId);
if (qt) {
clearTimeout(qt);
qrTimers.delete(sessionId);
}
const entry = active.get(sessionId);
if (entry) {
try {
entry.socket.end(undefined);
} catch {
/* ignore */
}
active.delete(sessionId);
}
}
export function getQrCode(sessionId: string): string | null {
return active.get(sessionId)?.qrCode ?? null;
}
export function getSocket(sessionId: string): WASocket | null {
return active.get(sessionId)?.socket ?? null;
}
export function isActive(sessionId: string): boolean {
return active.has(sessionId);
}
export async function initialize(): Promise<void> {
const rows = await db.query.sessions.findMany({
where: inArray(sessions.status, [
"CONNECTED",
"DISCONNECTED",
"CONNECTING",
]),
columns: { id: true, appId: true },
});
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)));
}
function mapBaileysPresence(
p: string,
): "typing" | "online" | "offline" | "paused" | "recording" {
switch (p) {
case "composing":
return "typing";
case "available":
return "online";
case "unavailable":
return "offline";
case "recording":
return "recording";
case "paused":
return "paused";
default:
return "offline";
}
}
export function getAppDekForSession(sessionId: string): Buffer | null {
return active.get(sessionId)?.dek ?? null;
}
export async function getDecryptedField(
sessionId: string,
appId: string,
encryptedValue: string | null,
): Promise<string | null> {
if (!encryptedValue) return null;
let dek = active.get(sessionId)?.dek ?? null;
if (!dek) {
try {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
});
if (!app) return null;
dek = unwrapDek(app.dekEncrypted);
} catch {
return null;
}
}
try {
return decryptColumn(encryptedValue, dek);
} catch {
return null;
}
}

View file

@ -0,0 +1,8 @@
// Webhook stub — replaced by full dispatcher in M5
export function emitWebhook(
appId: string,
event: string,
payload: Record<string, unknown>,
): void {
console.log("[webhook]", appId, event, JSON.stringify(payload));
}

81
src/lib/crypto.ts Normal file
View file

@ -0,0 +1,81 @@
import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
} from "node:crypto";
const ALG = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
function getMasterKey(): Buffer {
const raw = process.env.MASTER_ENCRYPTION_KEY;
if (!raw)
throw new Error("MASTER_ENCRYPTION_KEY is not set — server cannot start");
const buf = Buffer.from(raw, "base64");
if (buf.byteLength !== 32)
throw new Error(
"MASTER_ENCRYPTION_KEY must be exactly 32 bytes (base64-encoded)",
);
return buf;
}
export function generateDek(): Buffer {
return randomBytes(32);
}
export function wrapDek(dek: Buffer): string {
const kek = getMasterKey();
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, kek, iv);
const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
export function unwrapDek(wrapped: string): Buffer {
const kek = getMasterKey();
const buf = Buffer.from(wrapped, "base64");
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, kek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]);
}
export function encryptColumn(plaintext: string, dek: Buffer): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALG, dek, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
export function decryptColumn(ciphertext: string, dek: Buffer): string {
const buf = Buffer.from(ciphertext, "base64");
const iv = buf.subarray(0, IV_BYTES);
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const data = buf.subarray(IV_BYTES + TAG_BYTES);
const decipher = createDecipheriv(ALG, dek, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
export function encryptWithMasterKey(plaintext: string): string {
return encryptColumn(plaintext, getMasterKey());
}
export function decryptWithMasterKey(ciphertext: string): string {
return decryptColumn(ciphertext, getMasterKey());
}
export function validateMasterKey(): void {
getMasterKey();
}

23
src/lib/dek-helper.ts Normal file
View file

@ -0,0 +1,23 @@
import { eq } from "drizzle-orm";
import { db } from "#/db/index";
import { apps } from "#/db/schema";
import { unwrapDek } from "#/lib/crypto";
import { getAppDekForSession } from "#/lib/baileys";
export async function getSessionDek(
sessionId: string,
appId: string,
): Promise<Buffer | null> {
const inMemory = getAppDekForSession(sessionId);
if (inMemory) return inMemory;
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
});
if (!app) return null;
try {
return unwrapDek(app.dekEncrypted);
} catch {
return null;
}
}

16
src/lib/password.ts Normal file
View file

@ -0,0 +1,16 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}

69
src/lib/query.ts Normal file
View file

@ -0,0 +1,69 @@
import { QueryClient, queryOptions } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
async function apiFetch(url: string, init?: RequestInit) {
const res = await fetch(url, { credentials: "include", ...init });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export const appsQuery = queryOptions({
queryKey: ["apps"],
queryFn: () => apiFetch("/api/v1/admin/apps"),
});
export const appQuery = (appId: string) =>
queryOptions({
queryKey: ["apps", appId],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
});
export const apiKeysQuery = (appId: string) =>
queryOptions({
queryKey: ["apps", appId, "api-keys"],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
});
export const sessionsQuery = queryOptions({
queryKey: ["sessions"],
queryFn: () => apiFetch("/api/v1/admin/sessions"),
});
export const auditLogQuery = queryOptions({
queryKey: ["audit-log"],
queryFn: () => apiFetch("/api/v1/admin/audit-log?limit=50"),
});
interface SessionRow {
id: string;
appId: string;
appName: string;
status: string;
lastSeen: string | null;
createdAt: string;
reconnectCount: number;
}
export const adminSessionDetailQuery = (sessionId: string) =>
queryOptions({
queryKey: ["sessions", sessionId, "admin-detail"],
queryFn: () => apiFetch(`/api/v1/admin/sessions/${sessionId}`),
staleTime: 5_000,
});
export const appSessionsQuery = (appId: string) =>
queryOptions({
queryKey: ["apps", appId, "sessions"],
queryFn: async (): Promise<SessionRow[]> => {
const data: SessionRow[] = await apiFetch("/api/v1/admin/sessions");
return data.filter((s) => s.appId === appId);
},
});

View file

@ -0,0 +1,92 @@
import type { Job, Processor } from "bullmq";
import {
createWorker,
createQueue,
QUEUE_BROADCAST,
QUEUE_SEND,
} from "./index";
import { createMessage } from "#/lib/actions/messages";
import { unwrapDek } from "#/lib/crypto";
import { db } from "#/db/index";
import { apps } from "#/db/schema";
import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
export interface BroadcastJob {
sessionId: string;
appId: string;
recipients: string[];
content: {
type: "text" | "image" | "video" | "document" | "audio";
text?: string;
mediaUrl?: string;
caption?: string;
filename?: string;
voiceNote?: boolean;
};
rateMs: number;
}
async function processBroadcast(job: Job<BroadcastJob>): Promise<void> {
const { sessionId, appId, recipients, content, rateMs } = job.data;
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
});
if (!app) return;
const dek = unwrapDek(app.dekEncrypted);
const sendQueue = createQueue(QUEUE_SEND);
for (const jid of recipients) {
const whatsappMsgId = uuidv4();
const msg = await createMessage(
{
appId,
sessionId,
whatsappMsgId,
direction: "out",
type: content.type,
toJid: jid,
bodyEncrypted: content.text,
status: "queued",
isGroup: false,
},
dek,
);
const waContent = buildWaContent(content, jid);
await sendQueue.add("send", {
messageId: msg.id,
sessionId,
appId,
whatsappMsgId,
waMessage: { to: jid, content: waContent },
});
if (rateMs > 0) {
await new Promise((r) => setTimeout(r, rateMs));
}
}
await sendQueue.close();
}
function buildWaContent(
content: BroadcastJob["content"],
_jid: string,
): Record<string, unknown> {
if (content.type === "text") return { text: content.text };
return { url: content.mediaUrl, caption: content.caption };
}
const _g = globalThis as { __wahooBroadcastWorker?: ReturnType<typeof createWorker> };
export function startBroadcastWorker(): void {
if (_g.__wahooBroadcastWorker) return;
_g.__wahooBroadcastWorker = createWorker(QUEUE_BROADCAST, processBroadcast as Processor);
_g.__wahooBroadcastWorker.on("failed", (job, err) => {
console.error("[broadcast-worker] job failed", job?.id, err.message);
});
}

View file

@ -0,0 +1,32 @@
import type { Queue, Worker } from "bullmq";
import { createQueue, createWorker, QUEUE_CLEANUP } from "./index";
import { deleteOldMedia } from "#/lib/actions/messages";
const CLEANUP_REPEAT_CRON = "0 3 * * *"; // 3am daily
async function processCleanup(): Promise<void> {
const ttlDays = Number(process.env.MEDIA_TTL_DAYS ?? 30);
const deleted = await deleteOldMedia(ttlDays);
console.log(
`[cleanup-worker] deleted ${deleted} media records older than ${ttlDays} days`,
);
}
const _g = globalThis as { __wahooCleanupWorker?: Worker; __wahooCleanupQueue?: Queue };
export async function startCleanupWorker(): Promise<void> {
if (_g.__wahooCleanupWorker) return;
_g.__wahooCleanupQueue = createQueue(QUEUE_CLEANUP);
await _g.__wahooCleanupQueue.add(
"cleanup",
{},
{
repeat: { pattern: CLEANUP_REPEAT_CRON },
jobId: "media-cleanup-repeatable",
},
);
_g.__wahooCleanupWorker = createWorker(QUEUE_CLEANUP, processCleanup);
_g.__wahooCleanupWorker.on("failed", (job, err) => {
console.error("[cleanup-worker] job failed", job?.id, err.message);
});
}

30
src/lib/queue/index.ts Normal file
View file

@ -0,0 +1,30 @@
import { Queue, Worker } from "bullmq";
import type { ConnectionOptions } from "bullmq";
function getRedisConnection(): ConnectionOptions {
const url = process.env.REDIS_URL ?? "redis://localhost:6379";
const u = new URL(url);
return {
host: u.hostname,
port: Number(u.port) || 6379,
password: u.password || undefined,
db: u.pathname ? Number(u.pathname.slice(1)) || 0 : 0,
};
}
export const QUEUE_SEND = "msg-send";
export const QUEUE_BROADCAST = "msg-broadcast";
export const QUEUE_CLEANUP = "msg-cleanup";
export function createQueue(name: string): Queue {
return new Queue(name, { connection: getRedisConnection() });
}
export function createWorker(
name: string,
processor: ConstructorParameters<typeof Worker>[1],
): Worker {
return new Worker(name, processor, { connection: getRedisConnection() });
}
export { getRedisConnection };

View file

@ -0,0 +1,72 @@
import type { Job, Processor } from "bullmq";
import type { AnyMessageContent, WASocket } from "@whiskeysockets/baileys";
import { createWorker, QUEUE_SEND } from "./index";
import { getSocket } from "#/lib/baileys";
import { updateMessageStatus } from "#/lib/actions/messages";
import { emitWebhook } from "#/lib/baileys/webhook";
export interface SendJob {
messageId: string;
sessionId: string;
appId: string;
waMessage: { to: string; content: Record<string, unknown> };
whatsappMsgId: string;
}
// Resolve socket across Vite SSR / Nitro module boundary via globalThis
function resolveSocket(sessionId: string): WASocket | null {
const fromGlobal = (
globalThis as {
__wahooActiveSessions?: Map<string, { socket: WASocket }>;
}
).__wahooActiveSessions?.get(sessionId)?.socket;
return fromGlobal ?? getSocket(sessionId);
}
async function processSend(job: Job<SendJob>): Promise<void> {
const { messageId, sessionId, appId, waMessage, whatsappMsgId } = job.data;
const mapSize = (globalThis as { __wahooActiveSessions?: Map<string, unknown> }).__wahooActiveSessions?.size ?? -1;
const sock = resolveSocket(sessionId);
console.log(`[send-worker] resolveSocket(${sessionId}) → ${sock ? "found" : "null"} | map size: ${mapSize}`);
if (!sock) {
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
emitWebhook(appId, "message.failed", {
messageId,
sessionId,
reason: "session_not_connected",
});
return;
}
try {
await sock.sendMessage(
waMessage.to,
waMessage.content as unknown as AnyMessageContent,
{ messageId: whatsappMsgId },
);
await updateMessageStatus(whatsappMsgId, sessionId, "sent");
emitWebhook(appId, "message.sent", { messageId, sessionId, whatsappMsgId });
} catch (err) {
const reason =
err instanceof Error
? err.message
: typeof err === "object" && err !== null
? JSON.stringify(err)
: String(err);
console.error("[send-worker] sendMessage error:", reason, err);
await updateMessageStatus(whatsappMsgId, sessionId, "failed");
emitWebhook(appId, "message.failed", { messageId, sessionId, reason });
throw err instanceof Error ? err : new Error(reason);
}
}
const _g = globalThis as { __wahooSendWorker?: ReturnType<typeof createWorker> };
export function startSendWorker(): void {
if (_g.__wahooSendWorker) return;
_g.__wahooSendWorker = createWorker(QUEUE_SEND, processSend as Processor);
_g.__wahooSendWorker.on("failed", (job, err) => {
console.error("[send-worker] job failed", job?.id, err.message);
});
}

61
src/lib/session-api.ts Normal file
View file

@ -0,0 +1,61 @@
import { sealData, unsealData } from "iron-session";
import type { SessionData } from "./session";
const COOKIE_NAME = "wahoo_session";
const TTL = 60 * 60 * 24 * 7;
function getPassword(): string {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32)
throw new Error("SESSION_SECRET must be at least 32 characters");
return s;
}
export async function readSession(req: Request): Promise<SessionData | null> {
const cookieHeader = req.headers.get("cookie") ?? "";
const match = cookieHeader.match(
new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`),
);
if (!match) return null;
try {
return await unsealData<SessionData>(match[1], {
password: getPassword(),
ttl: TTL,
});
} catch {
return null;
}
}
export async function createSessionCookie(data: SessionData): Promise<string> {
const sealed = await sealData(data, { password: getPassword(), ttl: TTL });
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
return `${COOKIE_NAME}=${sealed}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${TTL}`;
}
export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
}
export function json(
data: unknown,
status = 200,
extraHeaders?: Record<string, string>,
): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", ...extraHeaders },
});
}
export function jsonError(message: string, status: number): Response {
return json({ error: message }, status);
}
export async function requireAuth(
req: Request,
): Promise<SessionData | Response> {
const session = await readSession(req);
if (!session?.userId) return jsonError("Unauthorized", 401);
return session;
}

5
src/lib/session.ts Normal file
View file

@ -0,0 +1,5 @@
export interface SessionData {
userId: string;
email: string;
totpVerified: boolean;
}

24
src/lib/storage/index.ts Normal file
View file

@ -0,0 +1,24 @@
export interface StorageDriver {
upload: (key: string, data: Buffer, mime: string) => Promise<void>;
signedUrl: (key: string, ttlSeconds: number) => Promise<string>;
delete: (key: string) => Promise<void>;
}
export type { LocalStorageDriver } from "./local";
export type { S3StorageDriver } from "./s3";
let _driver: StorageDriver | null = null;
export async function getStorageDriver(): Promise<StorageDriver> {
if (_driver) return _driver;
const type = process.env.STORAGE_DRIVER ?? "local";
if (type === "s3") {
const { S3StorageDriver } = await import("./s3");
_driver = new S3StorageDriver();
} else {
const { LocalStorageDriver } = await import("./local");
_driver = new LocalStorageDriver();
}
return _driver;
}

50
src/lib/storage/local.ts Normal file
View file

@ -0,0 +1,50 @@
import { createHmac } from "node:crypto";
import { mkdir, writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import type { StorageDriver } from "./index";
function getBasePath(): string {
return process.env.STORAGE_LOCAL_PATH ?? "./uploads";
}
function getSigningSecret(): string {
const s = process.env.STORAGE_SIGNING_SECRET ?? process.env.SESSION_SECRET;
if (!s)
throw new Error("STORAGE_SIGNING_SECRET or SESSION_SECRET must be set");
return s;
}
export class LocalStorageDriver implements StorageDriver {
async upload(key: string, data: Buffer, _mime: string): Promise<void> {
const fullPath = join(getBasePath(), key);
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
await mkdir(dir, { recursive: true });
await writeFile(fullPath, data);
}
async signedUrl(key: string, ttlSeconds: number): Promise<string> {
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
const sig = createHmac("sha256", getSigningSecret())
.update(`${key}:${expires}`)
.digest("hex");
const base = process.env.APP_URL ?? "http://localhost:3000";
return `${base}/api/v1/media/?key=${encodeURIComponent(key)}&expires=${expires}&sig=${sig}`;
}
async delete(key: string): Promise<void> {
try {
await unlink(join(getBasePath(), key));
} catch {
/* ignore missing */
}
}
verifySignedUrl(key: string, expires: number, sig: string): boolean {
const expected = createHmac("sha256", getSigningSecret())
.update(`${key}:${expires}`)
.digest("hex");
if (expected !== sig) return false;
if (Math.floor(Date.now() / 1000) > expires) return false;
return true;
}
}

55
src/lib/storage/s3.ts Normal file
View file

@ -0,0 +1,55 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand, GetObjectCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { StorageDriver } from "./index";
function getClient(): S3Client {
return new S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
});
}
function getBucket(): string {
const b = process.env.S3_BUCKET;
if (!b) throw new Error("S3_BUCKET env var not set");
return b;
}
export class S3StorageDriver implements StorageDriver {
async upload(key: string, data: Buffer, mime: string): Promise<void> {
const client = getClient();
await client.send(
new PutObjectCommand({
Bucket: getBucket(),
Key: key,
Body: data,
ContentType: mime,
}),
);
}
async signedUrl(key: string, ttlSeconds: number): Promise<string> {
const client = getClient();
return getSignedUrl(
client,
new GetObjectCommand({ Bucket: getBucket(), Key: key }),
{ expiresIn: ttlSeconds },
);
}
async delete(key: string): Promise<void> {
const client = getClient();
await client.send(
new DeleteObjectCommand({ Bucket: getBucket(), Key: key }),
);
}
}

35
src/lib/totp.ts Normal file
View file

@ -0,0 +1,35 @@
import * as OTPAuth from "otpauth";
import { randomBytes } from "node:crypto";
const ISSUER = "Wahoo";
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function generateTotpSecret(): string {
const bytes = randomBytes(20);
return Array.from(bytes)
.map((b) => B32[b & 0x1f])
.join("");
}
export function getTotpUri(email: string, secret: string): string {
const totp = new OTPAuth.TOTP({
issuer: ISSUER,
label: email,
secret,
digits: 6,
period: 30,
});
return totp.toString();
}
export function verifyTotp(secret: string, token: string): boolean {
const totp = new OTPAuth.TOTP({
issuer: ISSUER,
secret,
digits: 6,
period: 30,
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
}

View file

@ -1,7 +1,7 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

View file

@ -9,38 +9,624 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuditLogRouteImport } from './routes/audit-log'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SessionsIndexRouteImport } from './routes/sessions/index'
import { Route as AppsIndexRouteImport } from './routes/apps/index'
import { Route as SessionsNewRouteImport } from './routes/sessions/new'
import { Route as SessionsIdRouteImport } from './routes/sessions/$id'
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
import { Route as ApiReadyRouteImport } from './routes/api/ready'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AppsAppIdIndexRouteImport } from './routes/apps/$appId/index'
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as AppsAppIdSessionsIndexRouteImport } from './routes/apps/$appId/sessions/index'
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
import { Route as ApiV1MediaIndexRouteImport } from './routes/api/v1/media/index'
import { Route as AppsAppIdSessionsNewRouteImport } from './routes/apps/$appId/sessions/new'
import { Route as AppsAppIdSessionsSessionIdRouteImport } from './routes/apps/$appId/sessions/$sessionId'
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
import { Route as ApiV1AdminSessionsIndexRouteImport } from './routes/api/v1/admin/sessions/index'
import { Route as ApiV1AdminAppsIndexRouteImport } from './routes/api/v1/admin/apps/index'
import { Route as ApiV1SessionsIdQrRouteImport } from './routes/api/v1/sessions/$id/qr'
import { Route as ApiV1SessionsIdPresenceRouteImport } from './routes/api/v1/sessions/$id/presence'
import { Route as ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
import { Route as ApiV1SessionsIdDisconnectRouteImport } from './routes/api/v1/sessions/$id/disconnect'
import { Route as ApiV1SessionsIdMessagesIndexRouteImport } from './routes/api/v1/sessions/$id/messages/index'
import { Route as ApiV1AdminSessionsIdIndexRouteImport } from './routes/api/v1/admin/sessions/$id/index'
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
import { Route as ApiV1SessionsIdMessagesVideoRouteImport } from './routes/api/v1/sessions/$id/messages/video'
import { Route as ApiV1SessionsIdMessagesTextRouteImport } from './routes/api/v1/sessions/$id/messages/text'
import { Route as ApiV1SessionsIdMessagesImageRouteImport } from './routes/api/v1/sessions/$id/messages/image'
import { Route as ApiV1SessionsIdMessagesDocumentRouteImport } from './routes/api/v1/sessions/$id/messages/document'
import { Route as ApiV1SessionsIdMessagesBroadcastRouteImport } from './routes/api/v1/sessions/$id/messages/broadcast'
import { Route as ApiV1SessionsIdMessagesAudioRouteImport } from './routes/api/v1/sessions/$id/messages/audio'
import { Route as ApiV1AdminSessionsIdQrRouteImport } from './routes/api/v1/admin/sessions/$id/qr'
import { Route as ApiV1AdminSessionsIdPairRouteImport } from './routes/api/v1/admin/sessions/$id/pair'
import { Route as ApiV1AdminSessionsIdDisconnectRouteImport } from './routes/api/v1/admin/sessions/$id/disconnect'
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
import { Route as ApiV1SessionsIdMessagesMsgIdReadRouteImport } from './routes/api/v1/sessions/$id/messages/$msgId/read'
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuditLogRoute = AuditLogRouteImport.update({
id: '/audit-log',
path: '/audit-log',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsIndexRoute = SessionsIndexRouteImport.update({
id: '/sessions/',
path: '/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsIndexRoute = AppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsNewRoute = SessionsNewRouteImport.update({
id: '/sessions/new',
path: '/sessions/new',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsIdRoute = SessionsIdRouteImport.update({
id: '/sessions/$id',
path: '/sessions/$id',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdRoute = AppsAppIdRouteImport.update({
id: '/apps/$appId',
path: '/apps/$appId',
getParentRoute: () => rootRouteImport,
} as any)
const ApiReadyRoute = ApiReadyRouteImport.update({
id: '/api/ready',
path: '/api/ready',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdIndexRoute = AppsAppIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
id: '/api/auth/me',
path: '/api/auth/me',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({
id: '/api/auth/logout',
path: '/api/auth/logout',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
id: '/api/auth/login',
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
id: '/sessions/',
path: '/sessions/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
id: '/api/v1/sessions/',
path: '/api/v1/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1MediaIndexRoute = ApiV1MediaIndexRouteImport.update({
id: '/api/v1/media/',
path: '/api/v1/media/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdSessionsNewRoute = AppsAppIdSessionsNewRouteImport.update({
id: '/sessions/new',
path: '/sessions/new',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdSessionsSessionIdRoute =
AppsAppIdSessionsSessionIdRouteImport.update({
id: '/sessions/$sessionId',
path: '/sessions/$sessionId',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
id: '/api/v1/admin/audit-log',
path: '/api/v1/admin/audit-log',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthTotpEnrollRoute = ApiAuthTotpEnrollRouteImport.update({
id: '/api/auth/totp/enroll',
path: '/api/auth/totp/enroll',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdIndexRoute = ApiV1SessionsIdIndexRouteImport.update({
id: '/api/v1/sessions/$id/',
path: '/api/v1/sessions/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIndexRoute = ApiV1AdminSessionsIndexRouteImport.update({
id: '/api/v1/admin/sessions/',
path: '/api/v1/admin/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIndexRoute = ApiV1AdminAppsIndexRouteImport.update({
id: '/api/v1/admin/apps/',
path: '/api/v1/admin/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdQrRoute = ApiV1SessionsIdQrRouteImport.update({
id: '/api/v1/sessions/$id/qr',
path: '/api/v1/sessions/$id/qr',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdPresenceRoute = ApiV1SessionsIdPresenceRouteImport.update({
id: '/api/v1/sessions/$id/presence',
path: '/api/v1/sessions/$id/presence',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdPairRoute = ApiV1SessionsIdPairRouteImport.update({
id: '/api/v1/sessions/$id/pair',
path: '/api/v1/sessions/$id/pair',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdHealthRoute = ApiV1SessionsIdHealthRouteImport.update({
id: '/api/v1/sessions/$id/health',
path: '/api/v1/sessions/$id/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdDisconnectRoute =
ApiV1SessionsIdDisconnectRouteImport.update({
id: '/api/v1/sessions/$id/disconnect',
path: '/api/v1/sessions/$id/disconnect',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesIndexRoute =
ApiV1SessionsIdMessagesIndexRouteImport.update({
id: '/api/v1/sessions/$id/messages/',
path: '/api/v1/sessions/$id/messages/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdIndexRoute =
ApiV1AdminSessionsIdIndexRouteImport.update({
id: '/api/v1/admin/sessions/$id/',
path: '/api/v1/admin/sessions/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/',
path: '/api/v1/admin/apps/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesVideoRoute =
ApiV1SessionsIdMessagesVideoRouteImport.update({
id: '/api/v1/sessions/$id/messages/video',
path: '/api/v1/sessions/$id/messages/video',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesTextRoute =
ApiV1SessionsIdMessagesTextRouteImport.update({
id: '/api/v1/sessions/$id/messages/text',
path: '/api/v1/sessions/$id/messages/text',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesImageRoute =
ApiV1SessionsIdMessagesImageRouteImport.update({
id: '/api/v1/sessions/$id/messages/image',
path: '/api/v1/sessions/$id/messages/image',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesDocumentRoute =
ApiV1SessionsIdMessagesDocumentRouteImport.update({
id: '/api/v1/sessions/$id/messages/document',
path: '/api/v1/sessions/$id/messages/document',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesBroadcastRoute =
ApiV1SessionsIdMessagesBroadcastRouteImport.update({
id: '/api/v1/sessions/$id/messages/broadcast',
path: '/api/v1/sessions/$id/messages/broadcast',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesAudioRoute =
ApiV1SessionsIdMessagesAudioRouteImport.update({
id: '/api/v1/sessions/$id/messages/audio',
path: '/api/v1/sessions/$id/messages/audio',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdQrRoute = ApiV1AdminSessionsIdQrRouteImport.update({
id: '/api/v1/admin/sessions/$id/qr',
path: '/api/v1/admin/sessions/$id/qr',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdPairRoute =
ApiV1AdminSessionsIdPairRouteImport.update({
id: '/api/v1/admin/sessions/$id/pair',
path: '/api/v1/admin/sessions/$id/pair',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIdDisconnectRoute =
ApiV1AdminSessionsIdDisconnectRouteImport.update({
id: '/api/v1/admin/sessions/$id/disconnect',
path: '/api/v1/admin/sessions/$id/disconnect',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysIndexRoute =
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/',
path: '/api/v1/admin/apps/$id/api-keys/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdMessagesMsgIdReadRoute =
ApiV1SessionsIdMessagesMsgIdReadRouteImport.update({
id: '/api/v1/sessions/$id/messages/$msgId/read',
path: '/api/v1/sessions/$id/messages/$msgId/read',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdApiKeysKeyIdRoute =
ApiV1AdminAppsIdApiKeysKeyIdRouteImport.update({
id: '/api/v1/admin/apps/$id/api-keys/$keyId',
path: '/api/v1/admin/apps/$id/api-keys/$keyId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
'/sessions/': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId/': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps': typeof AppsIndexRoute
'/sessions': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/media': typeof ApiV1MediaIndexRoute
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/sessions/$id/messages': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/audit-log': typeof AuditLogRoute
'/login': typeof LoginRoute
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRouteWithChildren
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
'/sessions/': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/apps/$appId/': typeof AppsAppIdIndexRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/apps/$appId/sessions/$sessionId': typeof AppsAppIdSessionsSessionIdRoute
'/apps/$appId/sessions/new': typeof AppsAppIdSessionsNewRoute
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/presence': typeof ApiV1SessionsIdPresenceRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/sessions/$id/disconnect': typeof ApiV1AdminSessionsIdDisconnectRoute
'/api/v1/admin/sessions/$id/pair': typeof ApiV1AdminSessionsIdPairRoute
'/api/v1/admin/sessions/$id/qr': typeof ApiV1AdminSessionsIdQrRoute
'/api/v1/sessions/$id/messages/audio': typeof ApiV1SessionsIdMessagesAudioRoute
'/api/v1/sessions/$id/messages/broadcast': typeof ApiV1SessionsIdMessagesBroadcastRoute
'/api/v1/sessions/$id/messages/document': typeof ApiV1SessionsIdMessagesDocumentRoute
'/api/v1/sessions/$id/messages/image': typeof ApiV1SessionsIdMessagesImageRoute
'/api/v1/sessions/$id/messages/text': typeof ApiV1SessionsIdMessagesTextRoute
'/api/v1/sessions/$id/messages/video': typeof ApiV1SessionsIdMessagesVideoRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths:
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps/'
| '/sessions/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId/'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/apps/$appId/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
| '/api/v1/sessions/$id/messages/document'
| '/api/v1/sessions/$id/messages/image'
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to:
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/sessions/$id'
| '/sessions/new'
| '/apps'
| '/sessions'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/media'
| '/api/v1/sessions'
| '/apps/$appId/sessions'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps'
| '/api/v1/admin/sessions'
| '/api/v1/sessions/$id'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
| '/api/v1/sessions/$id/messages/document'
| '/api/v1/sessions/$id/messages/image'
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id'
| '/api/v1/admin/sessions/$id'
| '/api/v1/sessions/$id/messages'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys'
id:
| '__root__'
| '/'
| '/audit-log'
| '/login'
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps/'
| '/sessions/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/apps/$appId/'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/apps/$appId/sessions/$sessionId'
| '/apps/$appId/sessions/new'
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/apps/$appId/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/presence'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/sessions/$id/disconnect'
| '/api/v1/admin/sessions/$id/pair'
| '/api/v1/admin/sessions/$id/qr'
| '/api/v1/sessions/$id/messages/audio'
| '/api/v1/sessions/$id/messages/broadcast'
| '/api/v1/sessions/$id/messages/document'
| '/api/v1/sessions/$id/messages/image'
| '/api/v1/sessions/$id/messages/text'
| '/api/v1/sessions/$id/messages/video'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/sessions/$id/messages/$msgId/read'
| '/api/v1/admin/apps/$id/api-keys/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditLogRoute: typeof AuditLogRoute
LoginRoute: typeof LoginRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiReadyRoute: typeof ApiReadyRoute
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
SessionsIdRoute: typeof SessionsIdRoute
SessionsNewRoute: typeof SessionsNewRoute
AppsIndexRoute: typeof AppsIndexRoute
SessionsIndexRoute: typeof SessionsIndexRoute
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute
ApiAuthMeRoute: typeof ApiAuthMeRoute
ApiAuthTotpEnrollRoute: typeof ApiAuthTotpEnrollRoute
ApiV1AdminAuditLogRoute: typeof ApiV1AdminAuditLogRoute
ApiV1MediaIndexRoute: typeof ApiV1MediaIndexRoute
ApiV1SessionsIndexRoute: typeof ApiV1SessionsIndexRoute
ApiV1SessionsIdDisconnectRoute: typeof ApiV1SessionsIdDisconnectRoute
ApiV1SessionsIdHealthRoute: typeof ApiV1SessionsIdHealthRoute
ApiV1SessionsIdPairRoute: typeof ApiV1SessionsIdPairRoute
ApiV1SessionsIdPresenceRoute: typeof ApiV1SessionsIdPresenceRoute
ApiV1SessionsIdQrRoute: typeof ApiV1SessionsIdQrRoute
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
ApiV1AdminSessionsIdPairRoute: typeof ApiV1AdminSessionsIdPairRoute
ApiV1AdminSessionsIdQrRoute: typeof ApiV1AdminSessionsIdQrRoute
ApiV1SessionsIdMessagesAudioRoute: typeof ApiV1SessionsIdMessagesAudioRoute
ApiV1SessionsIdMessagesBroadcastRoute: typeof ApiV1SessionsIdMessagesBroadcastRoute
ApiV1SessionsIdMessagesDocumentRoute: typeof ApiV1SessionsIdMessagesDocumentRoute
ApiV1SessionsIdMessagesImageRoute: typeof ApiV1SessionsIdMessagesImageRoute
ApiV1SessionsIdMessagesTextRoute: typeof ApiV1SessionsIdMessagesTextRoute
ApiV1SessionsIdMessagesVideoRoute: typeof ApiV1SessionsIdMessagesVideoRoute
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
ApiV1SessionsIdMessagesIndexRoute: typeof ApiV1SessionsIdMessagesIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
ApiV1SessionsIdMessagesMsgIdReadRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRoute
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/audit-log': {
id: '/audit-log'
path: '/audit-log'
fullPath: '/audit-log'
preLoaderRoute: typeof AuditLogRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -48,11 +634,355 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/': {
id: '/sessions/'
path: '/sessions'
fullPath: '/sessions/'
preLoaderRoute: typeof SessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/': {
id: '/apps/'
path: '/apps'
fullPath: '/apps/'
preLoaderRoute: typeof AppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/new': {
id: '/sessions/new'
path: '/sessions/new'
fullPath: '/sessions/new'
preLoaderRoute: typeof SessionsNewRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/$id': {
id: '/sessions/$id'
path: '/sessions/$id'
fullPath: '/sessions/$id'
preLoaderRoute: typeof SessionsIdRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId': {
id: '/apps/$appId'
path: '/apps/$appId'
fullPath: '/apps/$appId'
preLoaderRoute: typeof AppsAppIdRouteImport
parentRoute: typeof rootRouteImport
}
'/api/ready': {
id: '/api/ready'
path: '/api/ready'
fullPath: '/api/ready'
preLoaderRoute: typeof ApiReadyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/': {
id: '/apps/$appId/'
path: '/'
fullPath: '/apps/$appId/'
preLoaderRoute: typeof AppsAppIdIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/auth/me': {
id: '/api/auth/me'
path: '/api/auth/me'
fullPath: '/api/auth/me'
preLoaderRoute: typeof ApiAuthMeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/logout': {
id: '/api/auth/logout'
path: '/api/auth/logout'
fullPath: '/api/auth/logout'
preLoaderRoute: typeof ApiAuthLogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/login': {
id: '/api/auth/login'
path: '/api/auth/login'
fullPath: '/api/auth/login'
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/sessions/': {
id: '/apps/$appId/sessions/'
path: '/sessions'
fullPath: '/apps/$appId/sessions/'
preLoaderRoute: typeof AppsAppIdSessionsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/sessions/': {
id: '/api/v1/sessions/'
path: '/api/v1/sessions'
fullPath: '/api/v1/sessions/'
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/media/': {
id: '/api/v1/media/'
path: '/api/v1/media'
fullPath: '/api/v1/media/'
preLoaderRoute: typeof ApiV1MediaIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId/sessions/new': {
id: '/apps/$appId/sessions/new'
path: '/sessions/new'
fullPath: '/apps/$appId/sessions/new'
preLoaderRoute: typeof AppsAppIdSessionsNewRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/sessions/$sessionId': {
id: '/apps/$appId/sessions/$sessionId'
path: '/sessions/$sessionId'
fullPath: '/apps/$appId/sessions/$sessionId'
preLoaderRoute: typeof AppsAppIdSessionsSessionIdRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/admin/audit-log': {
id: '/api/v1/admin/audit-log'
path: '/api/v1/admin/audit-log'
fullPath: '/api/v1/admin/audit-log'
preLoaderRoute: typeof ApiV1AdminAuditLogRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/totp/enroll': {
id: '/api/auth/totp/enroll'
path: '/api/auth/totp/enroll'
fullPath: '/api/auth/totp/enroll'
preLoaderRoute: typeof ApiAuthTotpEnrollRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/': {
id: '/api/v1/sessions/$id/'
path: '/api/v1/sessions/$id'
fullPath: '/api/v1/sessions/$id/'
preLoaderRoute: typeof ApiV1SessionsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/': {
id: '/api/v1/admin/sessions/'
path: '/api/v1/admin/sessions'
fullPath: '/api/v1/admin/sessions/'
preLoaderRoute: typeof ApiV1AdminSessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/': {
id: '/api/v1/admin/apps/'
path: '/api/v1/admin/apps'
fullPath: '/api/v1/admin/apps/'
preLoaderRoute: typeof ApiV1AdminAppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/qr': {
id: '/api/v1/sessions/$id/qr'
path: '/api/v1/sessions/$id/qr'
fullPath: '/api/v1/sessions/$id/qr'
preLoaderRoute: typeof ApiV1SessionsIdQrRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/presence': {
id: '/api/v1/sessions/$id/presence'
path: '/api/v1/sessions/$id/presence'
fullPath: '/api/v1/sessions/$id/presence'
preLoaderRoute: typeof ApiV1SessionsIdPresenceRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/pair': {
id: '/api/v1/sessions/$id/pair'
path: '/api/v1/sessions/$id/pair'
fullPath: '/api/v1/sessions/$id/pair'
preLoaderRoute: typeof ApiV1SessionsIdPairRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/health': {
id: '/api/v1/sessions/$id/health'
path: '/api/v1/sessions/$id/health'
fullPath: '/api/v1/sessions/$id/health'
preLoaderRoute: typeof ApiV1SessionsIdHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/disconnect': {
id: '/api/v1/sessions/$id/disconnect'
path: '/api/v1/sessions/$id/disconnect'
fullPath: '/api/v1/sessions/$id/disconnect'
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/': {
id: '/api/v1/sessions/$id/messages/'
path: '/api/v1/sessions/$id/messages'
fullPath: '/api/v1/sessions/$id/messages/'
preLoaderRoute: typeof ApiV1SessionsIdMessagesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/': {
id: '/api/v1/admin/sessions/$id/'
path: '/api/v1/admin/sessions/$id'
fullPath: '/api/v1/admin/sessions/$id/'
preLoaderRoute: typeof ApiV1AdminSessionsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/': {
id: '/api/v1/admin/apps/$id/'
path: '/api/v1/admin/apps/$id'
fullPath: '/api/v1/admin/apps/$id/'
preLoaderRoute: typeof ApiV1AdminAppsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/video': {
id: '/api/v1/sessions/$id/messages/video'
path: '/api/v1/sessions/$id/messages/video'
fullPath: '/api/v1/sessions/$id/messages/video'
preLoaderRoute: typeof ApiV1SessionsIdMessagesVideoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/text': {
id: '/api/v1/sessions/$id/messages/text'
path: '/api/v1/sessions/$id/messages/text'
fullPath: '/api/v1/sessions/$id/messages/text'
preLoaderRoute: typeof ApiV1SessionsIdMessagesTextRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/image': {
id: '/api/v1/sessions/$id/messages/image'
path: '/api/v1/sessions/$id/messages/image'
fullPath: '/api/v1/sessions/$id/messages/image'
preLoaderRoute: typeof ApiV1SessionsIdMessagesImageRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/document': {
id: '/api/v1/sessions/$id/messages/document'
path: '/api/v1/sessions/$id/messages/document'
fullPath: '/api/v1/sessions/$id/messages/document'
preLoaderRoute: typeof ApiV1SessionsIdMessagesDocumentRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/broadcast': {
id: '/api/v1/sessions/$id/messages/broadcast'
path: '/api/v1/sessions/$id/messages/broadcast'
fullPath: '/api/v1/sessions/$id/messages/broadcast'
preLoaderRoute: typeof ApiV1SessionsIdMessagesBroadcastRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/audio': {
id: '/api/v1/sessions/$id/messages/audio'
path: '/api/v1/sessions/$id/messages/audio'
fullPath: '/api/v1/sessions/$id/messages/audio'
preLoaderRoute: typeof ApiV1SessionsIdMessagesAudioRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/qr': {
id: '/api/v1/admin/sessions/$id/qr'
path: '/api/v1/admin/sessions/$id/qr'
fullPath: '/api/v1/admin/sessions/$id/qr'
preLoaderRoute: typeof ApiV1AdminSessionsIdQrRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/pair': {
id: '/api/v1/admin/sessions/$id/pair'
path: '/api/v1/admin/sessions/$id/pair'
fullPath: '/api/v1/admin/sessions/$id/pair'
preLoaderRoute: typeof ApiV1AdminSessionsIdPairRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/$id/disconnect': {
id: '/api/v1/admin/sessions/$id/disconnect'
path: '/api/v1/admin/sessions/$id/disconnect'
fullPath: '/api/v1/admin/sessions/$id/disconnect'
preLoaderRoute: typeof ApiV1AdminSessionsIdDisconnectRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/': {
id: '/api/v1/admin/apps/$id/api-keys/'
path: '/api/v1/admin/apps/$id/api-keys'
fullPath: '/api/v1/admin/apps/$id/api-keys/'
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/messages/$msgId/read': {
id: '/api/v1/sessions/$id/messages/$msgId/read'
path: '/api/v1/sessions/$id/messages/$msgId/read'
fullPath: '/api/v1/sessions/$id/messages/$msgId/read'
preLoaderRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/api-keys/$keyId': {
id: '/api/v1/admin/apps/$id/api-keys/$keyId'
path: '/api/v1/admin/apps/$id/api-keys/$keyId'
fullPath: '/api/v1/admin/apps/$id/api-keys/$keyId'
preLoaderRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
interface AppsAppIdRouteChildren {
AppsAppIdIndexRoute: typeof AppsAppIdIndexRoute
AppsAppIdSessionsSessionIdRoute: typeof AppsAppIdSessionsSessionIdRoute
AppsAppIdSessionsNewRoute: typeof AppsAppIdSessionsNewRoute
AppsAppIdSessionsIndexRoute: typeof AppsAppIdSessionsIndexRoute
}
const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
AppsAppIdIndexRoute: AppsAppIdIndexRoute,
AppsAppIdSessionsSessionIdRoute: AppsAppIdSessionsSessionIdRoute,
AppsAppIdSessionsNewRoute: AppsAppIdSessionsNewRoute,
AppsAppIdSessionsIndexRoute: AppsAppIdSessionsIndexRoute,
}
const AppsAppIdRouteWithChildren = AppsAppIdRoute._addFileChildren(
AppsAppIdRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditLogRoute: AuditLogRoute,
LoginRoute: LoginRoute,
ApiHealthRoute: ApiHealthRoute,
ApiReadyRoute: ApiReadyRoute,
AppsAppIdRoute: AppsAppIdRouteWithChildren,
SessionsIdRoute: SessionsIdRoute,
SessionsNewRoute: SessionsNewRoute,
AppsIndexRoute: AppsIndexRoute,
SessionsIndexRoute: SessionsIndexRoute,
ApiAuthLoginRoute: ApiAuthLoginRoute,
ApiAuthLogoutRoute: ApiAuthLogoutRoute,
ApiAuthMeRoute: ApiAuthMeRoute,
ApiAuthTotpEnrollRoute: ApiAuthTotpEnrollRoute,
ApiV1AdminAuditLogRoute: ApiV1AdminAuditLogRoute,
ApiV1MediaIndexRoute: ApiV1MediaIndexRoute,
ApiV1SessionsIndexRoute: ApiV1SessionsIndexRoute,
ApiV1SessionsIdDisconnectRoute: ApiV1SessionsIdDisconnectRoute,
ApiV1SessionsIdHealthRoute: ApiV1SessionsIdHealthRoute,
ApiV1SessionsIdPairRoute: ApiV1SessionsIdPairRoute,
ApiV1SessionsIdPresenceRoute: ApiV1SessionsIdPresenceRoute,
ApiV1SessionsIdQrRoute: ApiV1SessionsIdQrRoute,
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
ApiV1AdminSessionsIdPairRoute: ApiV1AdminSessionsIdPairRoute,
ApiV1AdminSessionsIdQrRoute: ApiV1AdminSessionsIdQrRoute,
ApiV1SessionsIdMessagesAudioRoute: ApiV1SessionsIdMessagesAudioRoute,
ApiV1SessionsIdMessagesBroadcastRoute: ApiV1SessionsIdMessagesBroadcastRoute,
ApiV1SessionsIdMessagesDocumentRoute: ApiV1SessionsIdMessagesDocumentRoute,
ApiV1SessionsIdMessagesImageRoute: ApiV1SessionsIdMessagesImageRoute,
ApiV1SessionsIdMessagesTextRoute: ApiV1SessionsIdMessagesTextRoute,
ApiV1SessionsIdMessagesVideoRoute: ApiV1SessionsIdMessagesVideoRoute,
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
ApiV1SessionsIdMessagesIndexRoute: ApiV1SessionsIdMessagesIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
ApiV1SessionsIdMessagesMsgIdReadRoute: ApiV1SessionsIdMessagesMsgIdReadRoute,
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
})
});
return router
return router;
}
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>
router: ReturnType<typeof getRouter>;
}
}

View file

@ -1,32 +1,48 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
useLocation,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "#/lib/query";
import { Sidebar } from "#/components/layout/sidebar";
import appCss from '../styles.css?url'
import appCss from "../styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Wahoo — WhatsApp API Gateway" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
shellComponent: RootDocument,
})
component: RootLayout,
});
function RootLayout() {
const location = useLocation();
const isLogin = location.pathname === "/login";
if (isLogin) {
return <Outlet />;
}
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-auto">
<Outlet />
</div>
</div>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
@ -35,20 +51,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{ position: "bottom-right" }}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</QueryClientProvider>
</body>
</html>
)
);
}

View file

@ -0,0 +1,84 @@
import { createFileRoute } from "@tanstack/react-router";
import { verifyPassword } from "#/lib/password";
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
import { writeAudit } from "#/lib/audit";
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_LIMIT = 10;
function checkLoginRateLimit(ip: string): boolean {
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
return true;
}
if (entry.count >= LOGIN_LIMIT) return false;
entry.count++;
return true;
}
export const Route = createFileRoute("/api/auth/login")({
server: {
handlers: {
POST: async ({ request }) => {
// x-forwarded-for is trusted as-is; deploy behind a trusted proxy or accept that IPs may be spoofed
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
request.headers.get("x-real-ip") ??
"unknown";
if (!checkLoginRateLimit(ip)) {
return jsonError("Too many login attempts", 429);
}
let body: { email: string; password: string; totpToken?: string };
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
const { email, password, totpToken } = body;
if (!email || !password)
return jsonError("email and password required", 400);
const user = await findUserByEmail(email);
if (!user) return jsonError("Invalid credentials", 401);
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) return jsonError("Invalid credentials", 401);
if (user.totpEnabled) {
if (!totpToken) return json({ requireTotp: true }, 200);
const totpOk = await verifyTotpToken(user, totpToken);
if (!totpOk) return jsonError("Invalid TOTP token", 401);
}
await writeAudit({
actorType: "user",
actorId: user.id,
action: "login",
resourceType: "user",
resourceId: user.id,
ip: ip === "unknown" ? undefined : ip,
userAgent: request.headers.get("user-agent") ?? undefined,
});
const cookie = await createSessionCookie({
userId: user.id,
email: user.email,
totpVerified: user.totpEnabled,
});
return json(
{ ok: true, user: { id: user.id, email: user.email } },
200,
{ "set-cookie": cookie },
);
},
},
},
});

View file

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { clearSessionCookie, json } from "#/lib/session-api";
export const Route = createFileRoute("/api/auth/logout")({
server: {
handlers: {
POST: () =>
json({ ok: true }, 200, { "set-cookie": clearSessionCookie() }),
},
},
});

14
src/routes/api/auth/me.ts Normal file
View file

@ -0,0 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
export const Route = createFileRoute("/api/auth/me")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
return json({ user: { id: session.userId, email: session.email } });
},
},
},
});

View file

@ -0,0 +1,47 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import {
enrollTotpSecret,
confirmTotpEnrollment,
disableTotp,
} from "#/lib/actions/auth";
export const Route = createFileRoute("/api/auth/totp/enroll")({
server: {
handlers: {
POST: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const result = await enrollTotpSecret(session.userId);
if (!result.ok) return jsonError(result.error, result.status);
return json({ secret: result.secret, uri: result.uri });
},
PUT: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { token: string };
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.token) return jsonError("token required", 400);
const result = await confirmTotpEnrollment(session.userId, body.token);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true });
},
DELETE: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
await disableTotp(session.userId);
return json({ ok: true });
},
},
},
});

13
src/routes/api/health.ts Normal file
View file

@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/health")({
server: {
handlers: {
GET: () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
},
});

38
src/routes/api/ready.ts Normal file
View file

@ -0,0 +1,38 @@
import { createFileRoute } from "@tanstack/react-router";
import { db } from "#/db/index";
import { sql } from "drizzle-orm";
import Redis from "ioredis";
export const Route = createFileRoute("/api/ready")({
server: {
handlers: {
GET: async () => {
const checks: Record<string, boolean> = { db: false, redis: false };
try {
await db.execute(sql`SELECT 1`);
checks.db = true;
} catch {}
try {
const redis = new Redis(
process.env.REDIS_URL ?? "redis://localhost:6379",
{ lazyConnect: true, connectTimeout: 2000 },
);
await redis.ping();
await redis.quit();
checks.redis = true;
} catch {}
const ok = Object.values(checks).every(Boolean);
return new Response(
JSON.stringify({ status: ok ? "ok" : "degraded", checks }),
{
status: ok ? 200 : 503,
headers: { "content-type": "application/json" },
},
);
},
},
},
});

View file

@ -0,0 +1,31 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { revokeAppApiKey } from "#/lib/actions/api-keys";
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/$keyId")({
server: {
handlers: {
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
const result = await revokeAppApiKey(
params.keyId,
params.id,
session.userId,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
if (!result.ok) return jsonError(result.error, result.status);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp } from "#/lib/actions/apps";
import { listAppApiKeys, createAppApiKey } from "#/lib/actions/api-keys";
export const Route = createFileRoute("/api/v1/admin/apps/$id/api-keys/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
return json(await listAppApiKeys(params.id));
},
POST: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
let body: { label?: string; env?: "live" | "test" } = {};
try {
body = await request.json();
} catch {}
const created = await createAppApiKey(
params.id,
session.userId,
body.label,
body.env,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return json(created, 201);
},
},
},
});

View file

@ -0,0 +1,68 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findUserApp, updateUserApp, deleteUserApp } from "#/lib/actions/apps";
export const Route = createFileRoute("/api/v1/admin/apps/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const app = await findUserApp(params.id, session.userId);
if (!app) return jsonError("Not found", 404);
return json({
id: app.id,
name: app.name,
maxSessions: app.maxSessions,
createdAt: app.createdAt,
});
},
PATCH: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { name?: string; maxSessions?: number };
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
const updates: { name?: string; maxSessions?: number } = {};
if (body.name !== undefined) updates.name = body.name;
if (body.maxSessions !== undefined)
updates.maxSessions = body.maxSessions;
if (Object.keys(updates).length === 0)
return jsonError("Nothing to update", 400);
const updated = await updateUserApp(
params.id,
session.userId,
updates,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
if (!updated) return jsonError("Not found", 404);
return json(updated);
},
DELETE: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const deleted = await deleteUserApp(params.id, session.userId, {
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
});
if (!deleted) return jsonError("Not found", 404);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,42 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { listUserApps, createUserApp } from "#/lib/actions/apps";
export const Route = createFileRoute("/api/v1/admin/apps/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const list = await listUserApps(session.userId);
return json(list);
},
POST: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
let body: { name?: string; maxSessions?: number };
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.name?.trim()) return jsonError("name required", 400);
const app = await createUserApp(
session.userId,
body.name,
body.maxSessions,
{
ip: request.headers.get("x-forwarded-for") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
},
);
return json(app, 201);
},
},
},
});

View file

@ -0,0 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { queryAuditLog } from "#/lib/actions/audit-log";
export const Route = createFileRoute("/api/v1/admin/audit-log")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const result = await queryAuditLog(session.userId, {
page: Number(url.searchParams.get("page") ?? 1),
limit: Number(url.searchParams.get("limit") ?? 50),
action: url.searchParams.get("action"),
resourceType: url.searchParams.get("resourceType"),
since: url.searchParams.get("since"),
until: url.searchParams.get("until"),
});
return json(result);
},
},
},
});

View file

@ -0,0 +1,24 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { findSession, disconnectSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/admin/sessions/$id/disconnect")({
server: {
handlers: {
POST: async ({ request, params }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
const row = await findSession(params.id, { userId: sess.userId });
if (!row) return jsonError("Not found", 404);
if (["DISCONNECTED", "LOGGED_OUT", "DEAD"].includes(row.status)) {
return json({ ok: true, status: row.status });
}
await disconnectSession(params.id, row.appId);
return json({ ok: true, status: "DISCONNECTED" });
},
},
},
});

View file

@ -0,0 +1,45 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { getDecryptedField } from "#/lib/baileys";
import { findSession, deleteSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/admin/sessions/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
const row = await findSession(params.id, { userId: sess.userId });
if (!row) return jsonError("Not found", 404);
const [phoneNumber, displayName] = await Promise.all([
getDecryptedField(row.id, row.appId, row.phoneNumber),
getDecryptedField(row.id, row.appId, row.displayName),
]);
return json({
id: row.id,
appId: row.appId,
status: row.status,
phoneNumber,
displayName,
reconnectCount: row.reconnectCount,
lastSeen: row.lastSeen,
createdAt: row.createdAt,
});
},
DELETE: async ({ request, params }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
const row = await findSession(params.id, { userId: sess.userId });
if (!row) return jsonError("Not found", 404);
await deleteSession(params.id, row.appId);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,54 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { startSession, getSocket } from "#/lib/baileys";
import { findSession, setSessionStatus } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/admin/sessions/$id/pair")({
server: {
handlers: {
POST: async ({ request, params }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
const row = await findSession(params.id, { userId: sess.userId });
if (!row) return jsonError("Not found", 404);
if (row.status === "CONNECTED")
return jsonError("Already connected", 409);
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
return jsonError("Session is no longer active", 410);
}
let body: { phone?: string };
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.phone) return jsonError("phone required", 400);
const phoneDigits = body.phone.replace(/\D/g, "");
if (phoneDigits.length < 7)
return jsonError("Invalid phone number", 400);
await setSessionStatus(params.id, "PENDING_PAIRING");
if (!getSocket(params.id)) {
await startSession(params.id, row.appId);
await new Promise((r) => setTimeout(r, 2000));
}
const sock = getSocket(params.id);
if (!sock)
return jsonError("Session not ready — retry in a moment", 503);
try {
const pairingCode = await sock.requestPairingCode(phoneDigits);
return json({ pairingCode });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return jsonError(`Pairing code request failed: ${msg}`, 502);
}
},
},
},
});

View file

@ -0,0 +1,31 @@
import { createFileRoute } from "@tanstack/react-router";
import QRCode from "qrcode";
import { json, jsonError, readSession } from "#/lib/session-api";
import { getQrCode } from "#/lib/baileys";
import { findSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/admin/sessions/$id/qr")({
server: {
handlers: {
GET: async ({ request, params }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
const row = await findSession(params.id, { userId: sess.userId });
if (!row) return jsonError("Not found", 404);
if (row.status === "CONNECTED")
return json({ error: "Already connected" }, 409);
if (row.status === "DEAD" || row.status === "LOGGED_OUT") {
return json({ error: "Session is no longer active" }, 410);
}
const qrRaw = getQrCode(params.id);
if (!qrRaw) return json({ waiting: true }, 202);
const qrPng = await QRCode.toDataURL(qrRaw);
return json({ qr: qrPng, qrRaw });
},
},
},
});

View file

@ -0,0 +1,48 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { listUserSessions, createSession } from "#/lib/actions/sessions";
import { writeAudit } from "#/lib/audit";
export const Route = createFileRoute("/api/v1/admin/sessions/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const rows = await listUserSessions(session.userId);
return json(rows);
},
POST: async ({ request }) => {
const sess = await readSession(request);
if (!sess?.userId) return jsonError("Unauthorized", 401);
let body: { appId?: string; connect_via?: "qr" | "pairing" } = {};
try {
body = await request.json();
} catch {
return jsonError("Invalid JSON", 400);
}
if (!body.appId) return jsonError("appId required", 400);
const via = body.connect_via === "pairing" ? "pairing" : "qr";
const result = await createSession(body.appId, via, sess.userId);
if (!result.ok) return jsonError(result.error, result.status);
writeAudit({
actorType: "user",
actorId: sess.userId,
action: "session.create",
resourceType: "session",
resourceId: result.session.id,
ip: request.headers.get("x-forwarded-for")?.split(",")[0].trim(),
userAgent: request.headers.get("user-agent") ?? undefined,
}).catch(console.error);
return json(result.session, 201);
},
},
},
});

View file

@ -0,0 +1,52 @@
import { createFileRoute } from "@tanstack/react-router";
import { jsonError } from "#/lib/session-api";
import { readFile } from "node:fs/promises";
import { join, extname } from "node:path";
import { LocalStorageDriver } from "#/lib/storage/local";
export const Route = createFileRoute("/api/v1/media/")({
server: {
handlers: {
GET: async ({ request }) => {
if (process.env.STORAGE_DRIVER === "s3") {
return jsonError("Media not served locally in S3 mode", 404);
}
const url = new URL(request.url);
const expires = Number(url.searchParams.get("expires"));
const sig = url.searchParams.get("sig") ?? "";
const key = url.searchParams.get("key") ?? "";
const driver = new LocalStorageDriver();
if (!driver.verifySignedUrl(key, expires, sig)) {
return jsonError("Invalid or expired media URL", 403);
}
const basePath = process.env.STORAGE_LOCAL_PATH ?? "./uploads";
try {
const data = await readFile(join(basePath, key));
const ext = extname(key).toLowerCase();
const mimeMap: Record<string, string> = {
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".webm": "video/webm",
".mov": "video/quicktime",
};
const contentType = mimeMap[ext] ?? "application/octet-stream";
return new Response(data, {
headers: { "content-type": contentType },
});
} catch {
return jsonError("Media not found", 404);
}
},
},
},
});

View file

@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession, disconnectSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/sessions/$id/disconnect")({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (
session.status === "DISCONNECTED" ||
session.status === "LOGGED_OUT" ||
session.status === "DEAD"
) {
return json({ ok: true, status: session.status });
}
await disconnectSession(params.id, auth.appId);
return json({ ok: true, status: "DISCONNECTED" });
},
},
},
});

View file

@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { isActive } from "#/lib/baileys";
import { findSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/sessions/$id/health")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
return json({
id: session.id,
status: session.status,
lastSeen: session.lastSeen,
reconnectCount: session.reconnectCount,
socketAlive: isActive(session.id),
});
},
},
},
});

View file

@ -0,0 +1,45 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { getDecryptedField } from "#/lib/baileys";
import { findSession, logoutAppSession } from "#/lib/actions/sessions";
export const Route = createFileRoute("/api/v1/sessions/$id/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
const [phoneNumber, displayName] = await Promise.all([
getDecryptedField(session.id, auth.appId, session.phoneNumber),
getDecryptedField(session.id, auth.appId, session.displayName),
]);
return json({
id: session.id,
status: session.status,
phoneNumber,
displayName,
reconnectCount: session.reconnectCount,
lastSeen: session.lastSeen,
createdAt: session.createdAt,
});
},
DELETE: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
await logoutAppSession(params.id, auth.appId);
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { findMessage, updateMessageStatus } from "#/lib/actions/messages";
import { getSocket } from "#/lib/baileys";
export const Route = createFileRoute(
"/api/v1/sessions/$id/messages/$msgId/read",
)({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (session.status !== "CONNECTED")
return jsonError("Session not connected", 409);
const msg = await findMessage(params.msgId, session.id);
if (!msg) return jsonError("Message not found", 404);
const sock = getSocket(session.id);
if (sock && msg.fromJid) {
await sock
.readMessages([
{
id: msg.whatsappMsgId,
remoteJid: msg.fromJid,
fromMe: false,
},
])
.catch(console.error);
}
await updateMessageStatus(msg.whatsappMsgId, session.id, "read");
return json({ ok: true });
},
},
},
});

View file

@ -0,0 +1,134 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { createMessage } from "#/lib/actions/messages";
import { getSessionDek } from "#/lib/dek-helper";
import { getStorageDriver } from "#/lib/storage";
import { createQueue, QUEUE_SEND } from "#/lib/queue";
import { v4 as uuidv4 } from "uuid";
import Busboy from "busboy";
import { Readable } from "node:stream";
function parseMultipart(
contentType: string,
body: ReadableStream<Uint8Array>,
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
return new Promise((resolve, reject) => {
const bb = Busboy({ headers: { "content-type": contentType } });
const fields: Record<string, string> = {};
let file: { buffer: Buffer; mime: string; name: string } | null = null;
bb.on("field", (name, val) => {
fields[name] = val;
});
bb.on("file", (name, stream, info) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
file = {
buffer: Buffer.concat(chunks),
mime: info.mimeType || "audio/ogg",
name: info.filename || name,
};
});
});
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
nodeStream.pipe(bb);
});
}
export const Route = createFileRoute("/api/v1/sessions/$id/messages/audio")({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (session.status !== "CONNECTED")
return jsonError("Session not connected", 409);
const dek = await getSessionDek(session.id, auth.appId);
if (!dek) return jsonError("Session DEK unavailable", 503);
const ct = request.headers.get("content-type") ?? "";
let to: string,
voiceNote = false,
mediaUrl: string | undefined;
let uploadedKey: string | undefined;
let mime = "audio/ogg; codecs=opus";
if (ct.includes("multipart/form-data")) {
if (!request.body) return jsonError("Missing body", 400);
const { fields, file } = await parseMultipart(ct, request.body);
if (!fields.to) return jsonError("Missing field: to", 400);
if (!file) return jsonError("Missing file field", 400);
to = fields.to;
voiceNote = fields.voice_note === "true";
const rawMime = file.mime || "audio/ogg";
// OGG without codec hint → assume Opus (WhatsApp requires it)
mime = rawMime === "audio/ogg" ? "audio/ogg; codecs=opus" : rawMime;
const ext = mime.split("/")[1]?.split(";")[0]?.trim() ?? "ogg";
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
const driver = await getStorageDriver();
await driver.upload(key, file.buffer, mime);
uploadedKey = key;
mediaUrl = await driver.signedUrl(key, 3600);
} else {
const body = await request.json().catch(() => null);
if (!body?.to || !body?.url)
return jsonError("Missing required fields: to, url", 400);
to = body.to;
voiceNote = body.voice_note === true;
mediaUrl = body.url;
}
if (!to) return jsonError("Missing field: to", 400);
const whatsappMsgId = uuidv4();
const msg = await createMessage(
{
appId: auth.appId,
sessionId: session.id,
whatsappMsgId,
direction: "out",
type: "audio",
toJid: to,
mediaUrl: mediaUrl ?? null,
mediaKey: uploadedKey ?? null,
mediaMime: mime,
status: "queued",
isGroup: to.endsWith("@g.us"),
},
dek,
);
const queue = createQueue(QUEUE_SEND);
await queue.add("send", {
messageId: msg.id,
sessionId: session.id,
appId: auth.appId,
whatsappMsgId,
waMessage: {
to,
content: {
audio: { url: mediaUrl },
ptt: voiceNote,
mimetype: mime,
},
},
});
await queue.close();
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
},
},
},
});

View file

@ -0,0 +1,62 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { createQueue, QUEUE_BROADCAST } from "#/lib/queue";
const DEFAULT_RATE_MS = 1000;
export const Route = createFileRoute("/api/v1/sessions/$id/messages/broadcast")(
{
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (session.status !== "CONNECTED")
return jsonError("Session not connected", 409);
const body = await request.json().catch(() => null);
if (
!body?.recipients ||
!Array.isArray(body.recipients) ||
body.recipients.length === 0
) {
return jsonError(
"Missing required field: recipients (non-empty array)",
400,
);
}
if (!body.type || (!body.text && !body.url)) {
return jsonError("Missing message content (text or url)", 400);
}
const queue = createQueue(QUEUE_BROADCAST);
const job = await queue.add("broadcast", {
sessionId: session.id,
appId: auth.appId,
recipients: body.recipients,
content: {
type: body.type,
text: body.text,
mediaUrl: body.url,
caption: body.caption,
filename: body.filename,
voiceNote: body.voice_note,
},
rateMs: body.rate_ms ?? DEFAULT_RATE_MS,
});
await queue.close();
return json(
{ jobId: job.id, recipientCount: body.recipients.length },
202,
);
},
},
},
},
);

View file

@ -0,0 +1,133 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { createMessage } from "#/lib/actions/messages";
import { getSessionDek } from "#/lib/dek-helper";
import { getStorageDriver } from "#/lib/storage";
import { createQueue, QUEUE_SEND } from "#/lib/queue";
import { v4 as uuidv4 } from "uuid";
import Busboy from "busboy";
import { Readable } from "node:stream";
function parseMultipart(
contentType: string,
body: ReadableStream<Uint8Array>,
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
return new Promise((resolve, reject) => {
const bb = Busboy({ headers: { "content-type": contentType } });
const fields: Record<string, string> = {};
let file: { buffer: Buffer; mime: string; name: string } | null = null;
bb.on("field", (name, val) => {
fields[name] = val;
});
bb.on("file", (_name, stream, info) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
file = {
buffer: Buffer.concat(chunks),
mime: info.mimeType || "application/octet-stream",
name: info.filename || "file",
};
});
});
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
nodeStream.pipe(bb);
});
}
export const Route = createFileRoute("/api/v1/sessions/$id/messages/document")({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (session.status !== "CONNECTED")
return jsonError("Session not connected", 409);
const dek = await getSessionDek(session.id, auth.appId);
if (!dek) return jsonError("Session DEK unavailable", 503);
const ct = request.headers.get("content-type") ?? "";
let to: string,
filename: string | undefined,
mediaUrl: string | undefined;
let uploadedKey: string | undefined;
let mime = "application/octet-stream";
if (ct.includes("multipart/form-data")) {
if (!request.body) return jsonError("Missing body", 400);
const { fields, file } = await parseMultipart(ct, request.body);
if (!fields.to) return jsonError("Missing field: to", 400);
if (!file) return jsonError("Missing file field", 400);
to = fields.to;
filename = fields.filename ?? file.name;
mime = file.mime.length > 0 ? file.mime : mime;
const ext = filename.split(".").pop() ?? "bin";
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
const driver = await getStorageDriver();
await driver.upload(key, file.buffer, mime);
uploadedKey = key;
mediaUrl = await driver.signedUrl(key, 3600);
} else {
const body = await request.json().catch(() => null);
if (!body?.to || !body?.url)
return jsonError("Missing required fields: to, url", 400);
to = body.to;
filename = body.filename;
mediaUrl = body.url;
}
if (!to) return jsonError("Missing field: to", 400);
const whatsappMsgId = uuidv4();
const msg = await createMessage(
{
appId: auth.appId,
sessionId: session.id,
whatsappMsgId,
direction: "out",
type: "document",
toJid: to,
bodyEncrypted: filename,
mediaUrl: mediaUrl ?? null,
mediaKey: uploadedKey ?? null,
mediaMime: mime,
status: "queued",
isGroup: to.endsWith("@g.us"),
},
dek,
);
const queue = createQueue(QUEUE_SEND);
await queue.add("send", {
messageId: msg.id,
sessionId: session.id,
appId: auth.appId,
whatsappMsgId,
waMessage: {
to,
content: {
document: { url: mediaUrl },
mimetype: mime,
fileName: filename,
},
},
});
await queue.close();
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
},
},
},
});

View file

@ -0,0 +1,125 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { createMessage } from "#/lib/actions/messages";
import { getSessionDek } from "#/lib/dek-helper";
import { getStorageDriver } from "#/lib/storage";
import { createQueue, QUEUE_SEND } from "#/lib/queue";
import { v4 as uuidv4 } from "uuid";
import Busboy from "busboy";
import { Readable } from "node:stream";
function parseMultipart(
contentType: string,
body: ReadableStream<Uint8Array>,
): Promise<{ fields: Record<string, string>; file: { buffer: Buffer; mime: string; name: string } | null }> {
return new Promise((resolve, reject) => {
const bb = Busboy({ headers: { "content-type": contentType } });
const fields: Record<string, string> = {};
let file: { buffer: Buffer; mime: string; name: string } | null = null;
bb.on("field", (name, val) => {
fields[name] = val;
});
bb.on("file", (_name, stream, info) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
file = {
buffer: Buffer.concat(chunks),
mime: info.mimeType || "image/jpeg",
name: info.filename || "image.jpg",
};
});
});
bb.on("finish", () => resolve({ fields, file }));
bb.on("error", reject);
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
nodeStream.pipe(bb);
});
}
export const Route = createFileRoute("/api/v1/sessions/$id/messages/image")({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
if (session.status !== "CONNECTED")
return jsonError("Session not connected", 409);
const dek = await getSessionDek(session.id, auth.appId);
if (!dek) return jsonError("Session DEK unavailable", 503);
const ct = request.headers.get("content-type") ?? "";
let to: string,
caption: string | undefined,
mediaUrl: string | undefined;
let uploadedKey: string | undefined;
if (ct.includes("multipart/form-data")) {
if (!request.body) return jsonError("Missing body", 400);
const { fields, file } = await parseMultipart(ct, request.body);
if (!fields.to) return jsonError("Missing field: to", 400);
if (!file) return jsonError("Missing file field", 400);
to = fields.to;
caption = fields.caption;
const mime = file.mime.length > 0 ? file.mime : "image/jpeg";
const ext = mime.split("/")[1] ?? "jpg";
const key = `media/${auth.appId}/${session.id}/${uuidv4()}.${ext}`;
const driver = await getStorageDriver();
await driver.upload(key, file.buffer, mime);
uploadedKey = key;
mediaUrl = await driver.signedUrl(key, 3600);
} else {
const body = await request.json().catch(() => null);
if (!body?.to || !body?.url)
return jsonError("Missing required fields: to, url", 400);
to = body.to;
caption = body.caption;
mediaUrl = body.url;
}
if (!to) return jsonError("Missing field: to", 400);
const whatsappMsgId = uuidv4();
const msg = await createMessage(
{
appId: auth.appId,
sessionId: session.id,
whatsappMsgId,
direction: "out",
type: "image",
toJid: to,
bodyEncrypted: caption,
mediaUrl: mediaUrl ?? null,
mediaKey: uploadedKey ?? null,
mediaMime: "image/jpeg",
status: "queued",
isGroup: to.endsWith("@g.us"),
},
dek,
);
const queue = createQueue(QUEUE_SEND);
await queue.add("send", {
messageId: msg.id,
sessionId: session.id,
appId: auth.appId,
whatsappMsgId,
waMessage: { to, content: { image: { url: mediaUrl }, caption } },
});
await queue.close();
return json({ id: msg.id, whatsappMsgId, status: "queued" }, 202);
},
},
},
});

View file

@ -0,0 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { findSession } from "#/lib/actions/sessions";
import { listMessages } from "#/lib/actions/messages";
import { getSessionDek } from "#/lib/dek-helper";
export const Route = createFileRoute("/api/v1/sessions/$id/messages/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const session = await findSession(params.id, { appId: auth.appId });
if (!session) return jsonError("Not found", 404);
const url = new URL(request.url);
const sinceParam = url.searchParams.get("since");
const limitParam = url.searchParams.get("limit");
const directionParam = url.searchParams.get("direction") as
| "in"
| "out"
| null;
const dek = await getSessionDek(session.id, auth.appId);
if (!dek) return jsonError("Session DEK unavailable", 503);
const msgs = await listMessages(
session.id,
{
since: sinceParam ? new Date(sinceParam) : undefined,
limit: limitParam ? Number(limitParam) : undefined,
direction: directionParam ?? undefined,
},
dek,
);
return json({ messages: msgs });
},
},
},
});

Some files were not shown because too many files have changed in this diff Show more