feat: openapi
This commit is contained in:
parent
abf5029579
commit
b40333a2cd
20 changed files with 4235 additions and 204 deletions
260
README.md
260
README.md
|
|
@ -1,222 +1,82 @@
|
|||
Welcome to your new TanStack Start app!
|
||||
# Wahoo
|
||||
|
||||
# Getting Started
|
||||
**Self-hosted WhatsApp gateway** — send and receive WhatsApp messages through a clean REST API, with multi-app support, end-to-end encryption, webhooks, and a built-in admin dashboard.
|
||||
|
||||
To run this application:
|
||||
> **Not affiliated with WhatsApp or Meta. Unofficial API. Use at your own risk.**
|
||||
> See [docs/baileys-risk.md](docs/baileys-risk.md) for full risk disclosure.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://tanstack.com/start)
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
See [docs/quickstart.md](docs/quickstart.md) for the full guide. Short version:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun --bun run dev
|
||||
git clone https://github.com/yourorg/wahoo.git && cd wahoo
|
||||
cp .env.example .env # fill in MASTER_ENCRYPTION_KEY and SESSION_SECRET
|
||||
docker compose up -d
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
# Building For Production
|
||||
---
|
||||
|
||||
To build this application for production:
|
||||
## Features
|
||||
|
||||
```bash
|
||||
bun --bun run build
|
||||
```
|
||||
- **Multi-app** — create isolated apps, each with its own encryption key and API keys
|
||||
- **Multi-session** — run multiple WhatsApp numbers per app
|
||||
- **Encrypted at rest** — contacts and session credentials encrypted with AES-256-GCM, per-app data encryption keys wrapped by a master key
|
||||
- **Encryption key rotation** — rotate the app DEK in the background without downtime
|
||||
- **REST API** — send text, image, video, document, and audio messages; broadcast to multiple recipients
|
||||
- **Webhooks** — receive real-time events (message received/sent/delivered/read, session status, presence) with HMAC-SHA256 signatures
|
||||
- **Contact management** — import via CSV, sync from WhatsApp, tag and group contacts, custom field schemas
|
||||
- **Audit log** — tamper-evident log of all admin and API actions
|
||||
- **TOTP 2FA** — protect admin accounts with time-based one-time passwords
|
||||
- **OpenAPI docs** — interactive Swagger UI at `/api/docs`
|
||||
- **Admin dashboard** — full web UI for managing apps, sessions, webhooks, contacts, and monitoring
|
||||
|
||||
## Testing
|
||||
---
|
||||
|
||||
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
bun --bun run test
|
||||
```
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend + SSR | TanStack Start (React, Vite) |
|
||||
| Routing | TanStack Router (file-based) |
|
||||
| API | TanStack Start server handlers (Nitro adapter) |
|
||||
| Database | PostgreSQL via Drizzle ORM |
|
||||
| Job queue | BullMQ (Redis) |
|
||||
| WhatsApp protocol | Baileys (unofficial, reverse-engineered) |
|
||||
| Encryption | AES-256-GCM, per-app DEK + master KEK |
|
||||
| Auth | Iron Session (cookie) + API key (Bearer) |
|
||||
|
||||
## Styling
|
||||
---
|
||||
|
||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||
## Documentation
|
||||
|
||||
### Removing Tailwind CSS
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step getting started guide |
|
||||
| [docs/api.md](docs/api.md) | REST endpoint reference |
|
||||
| [docs/webhooks.md](docs/webhooks.md) | Event catalog, HMAC verification |
|
||||
| [docs/security.md](docs/security.md) | Encryption model, key rotation, audit log |
|
||||
| [docs/deployment.md](docs/deployment.md) | Docker Compose deploy, backup, restore |
|
||||
| [docs/baileys-risk.md](docs/baileys-risk.md) | Baileys risks, ToS, number warming guide |
|
||||
|
||||
If you prefer not to use Tailwind CSS:
|
||||
Interactive API reference available at `http://localhost:3000/api/docs` when running.
|
||||
|
||||
1. Remove the demo pages in `src/routes/demo/`
|
||||
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
||||
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
||||
4. Uninstall the packages: `bun install @tailwindcss/vite tailwindcss -D`
|
||||
---
|
||||
|
||||
## Linting & Formatting
|
||||
## Baileys risk notice
|
||||
|
||||
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:
|
||||
Wahoo uses [Baileys](https://github.com/WhiskeySockets/Baileys), an unofficial WhatsApp Web client built through reverse engineering. This violates WhatsApp's Terms of Service (§2c). Your number may be temporarily or permanently banned. Read [docs/baileys-risk.md](docs/baileys-risk.md) before deploying in production.
|
||||
|
||||
```bash
|
||||
bun --bun run lint
|
||||
bun --bun run format
|
||||
bun --bun run check
|
||||
```
|
||||
---
|
||||
|
||||
## Deploy with Nitro
|
||||
## License
|
||||
|
||||
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
||||
Wahoo is released under the [MIT License](LICENSE).
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node dist/server/index.mjs
|
||||
```
|
||||
|
||||
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
|
||||
|
||||
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/).
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
### Adding A Route
|
||||
|
||||
To add a new route to your application just add a new file in the `./src/routes` directory.
|
||||
|
||||
TanStack will automatically generate the content of the route file for you.
|
||||
|
||||
Now that you have two routes you can use a `Link` component to navigate between them.
|
||||
|
||||
### Adding Links
|
||||
|
||||
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||
|
||||
```tsx
|
||||
import { Link } from "@tanstack/react-router";
|
||||
```
|
||||
|
||||
Then anywhere in your JSX you can use it like so:
|
||||
|
||||
```tsx
|
||||
<Link to="/about">About</Link>
|
||||
```
|
||||
|
||||
This will create a link that will navigate to the `/about` route.
|
||||
|
||||
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
||||
|
||||
### Using A Layout
|
||||
|
||||
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
|
||||
|
||||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
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" },
|
||||
],
|
||||
}),
|
||||
shellComponent: ({ children }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
</header>
|
||||
{children}
|
||||
<Scripts />
|
||||
</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).
|
||||
|
||||
## Server Functions
|
||||
|
||||
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";
|
||||
|
||||
const getServerTime = createServerFn({
|
||||
method: "GET",
|
||||
}).handler(async () => {
|
||||
return new Date().toISOString();
|
||||
});
|
||||
|
||||
// Use in a component
|
||||
function MyComponent() {
|
||||
const [time, setTime] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getServerTime().then(setTime);
|
||||
}, []);
|
||||
|
||||
return <div>Server time: {time}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
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";
|
||||
|
||||
export const Route = createFileRoute("/api/hello")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => json({ message: "Hello, World!" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Data Fetching
|
||||
|
||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||
|
||||
For example:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/people")({
|
||||
loader: async () => {
|
||||
const response = await fetch("https://swapi.dev/api/people");
|
||||
return response.json();
|
||||
},
|
||||
component: PeopleComponent,
|
||||
});
|
||||
|
||||
function PeopleComponent() {
|
||||
const data = Route.useLoaderData();
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||
|
||||
# Demo files
|
||||
|
||||
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
|
||||
|
||||
# Learn More
|
||||
|
||||
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||
|
||||
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
|
||||
Baileys is separately licensed under the [Apache 2.0 License](https://github.com/WhiskeySockets/Baileys/blob/master/LICENSE). Wahoo does not bundle Baileys source code; it lists it as an npm dependency.
|
||||
|
|
|
|||
71
docs/baileys-risk.md
Normal file
71
docs/baileys-risk.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Baileys Risk Notice
|
||||
|
||||
## What is Baileys?
|
||||
|
||||
[Baileys](https://github.com/WhiskeySockets/Baileys) is an open-source, community-maintained Node.js library that implements the WhatsApp Web protocol through reverse engineering. Wahoo uses Baileys as its WhatsApp connectivity layer.
|
||||
|
||||
## Unofficial status
|
||||
|
||||
Baileys is **not** an official WhatsApp or Meta product. It is not endorsed, supported, or sanctioned by WhatsApp Inc. or Meta Platforms, Inc. The library was built by analyzing the WhatsApp Web client's network traffic and is subject to break without warning whenever WhatsApp updates its protocol.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
Using Baileys (and by extension Wahoo) to connect to WhatsApp violates WhatsApp's Terms of Service, specifically **Section 2(c)**, which prohibits:
|
||||
|
||||
> "reverse engineering, altering, modifying, creating derivative works from, decompiling, or extracting code from our Services"
|
||||
|
||||
and
|
||||
|
||||
> "using automated or programmatic methods to send or receive messages"
|
||||
|
||||
By deploying Wahoo you accept this risk. The Wahoo project and its contributors provide this software as-is and are not liable for any account suspension, number ban, or legal consequences that result from its use.
|
||||
|
||||
## Risk of number bans
|
||||
|
||||
WhatsApp actively detects and bans numbers it believes are operating in an automated or bulk-messaging capacity. Bans can be:
|
||||
|
||||
- **Temporary** — number blocked for hours to days
|
||||
- **Permanent** — number permanently disabled from WhatsApp
|
||||
|
||||
Risk factors that increase ban likelihood:
|
||||
|
||||
- Sending high message volumes before the number is "warmed up"
|
||||
- Sending unsolicited or bulk promotional messages
|
||||
- Using a freshly registered number
|
||||
- Receiving a high ratio of "block" or "report spam" actions from recipients
|
||||
- Rapid session reconnects or unusual connection patterns
|
||||
|
||||
## Number warming guide
|
||||
|
||||
New numbers should be introduced to automation gradually:
|
||||
|
||||
| Week | Max messages/day | Notes |
|
||||
|------|-----------------|-------|
|
||||
| 1 | 50 | Personal-style messages only |
|
||||
| 2 | 100 | Continue conversational style |
|
||||
| 3 | 200 | Can begin light business use |
|
||||
| 4+ | 500+ | Ramp up based on engagement |
|
||||
|
||||
General principles:
|
||||
|
||||
- **Never** send to people who have not opted in
|
||||
- **Never** send the same message text to many recipients simultaneously (vary content)
|
||||
- Keep message quality high — useful, relevant, and personalised content
|
||||
- Monitor your delivery rates and stop if you see unusual failures
|
||||
- Keep session connection stable — avoid frequent disconnect/reconnect cycles
|
||||
- Use a number that has an active personal WhatsApp history before automating it
|
||||
|
||||
## Monitoring number health
|
||||
|
||||
Use the `/api/v1/sessions/{id}/health` endpoint to check session status regularly. If a session enters `DEAD` or `LOGGED_OUT` status unexpectedly, your number may have been flagged.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
The Wahoo project and its contributors:
|
||||
|
||||
- Are not affiliated with WhatsApp Inc. or Meta Platforms, Inc.
|
||||
- Provide no warranty, expressed or implied
|
||||
- Accept no liability for account bans, service disruption, legal action, or any other consequence arising from use of this software
|
||||
- Recommend evaluating the [official WhatsApp Business API](https://www.whatsapp.com/business/api) for production business use cases
|
||||
|
||||
Use this software at your own risk.
|
||||
126
docs/quickstart.md
Normal file
126
docs/quickstart.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Quickstart
|
||||
|
||||
Get Wahoo running locally in under 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- A WhatsApp account (personal or business) for testing
|
||||
|
||||
## 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourorg/wahoo.git
|
||||
cd wahoo
|
||||
```
|
||||
|
||||
## 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and fill in the two required secrets:
|
||||
|
||||
```bash
|
||||
# Generate a 32-byte master encryption key (base64)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
|
||||
# Generate a session secret (at least 32 characters)
|
||||
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
```
|
||||
|
||||
Set the values in `.env`:
|
||||
|
||||
```env
|
||||
MASTER_ENCRYPTION_KEY=<output from first command>
|
||||
SESSION_SECRET=<output from second command>
|
||||
```
|
||||
|
||||
## 3. Start all services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL, Redis, and the Wahoo app. Wait 10–15 seconds for the database migrations to complete.
|
||||
|
||||
## 4. Log in
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
Use the default admin credentials set in your `.env` (check `ADMIN_EMAIL` / `ADMIN_PASSWORD`). On first run these are created automatically from those env vars.
|
||||
|
||||
## 5. Create an app
|
||||
|
||||
1. Click **Apps** in the sidebar
|
||||
2. Click **New app**, give it a name, click **Create**
|
||||
3. Open the app — you'll land on the **API Keys** tab
|
||||
|
||||
## 6. Generate an API key
|
||||
|
||||
1. Click **New API key**, optionally label it
|
||||
2. Copy the key (`wak_live_...`) — it's shown only once
|
||||
|
||||
## 7. Create a WhatsApp session
|
||||
|
||||
Using your new API key:
|
||||
|
||||
```bash
|
||||
export BASE_URL=http://localhost:3000
|
||||
export API_KEY=wak_live_...
|
||||
|
||||
# Create a session (QR mode)
|
||||
curl -s -X POST "$BASE_URL/api/v1/sessions/" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"via":"qr"}' | jq .
|
||||
```
|
||||
|
||||
Note the `id` field — that's your `SESSION_ID`.
|
||||
|
||||
## 8. Scan the QR code
|
||||
|
||||
```bash
|
||||
SESSION_ID=<id from previous step>
|
||||
|
||||
curl -s "$BASE_URL/api/v1/sessions/$SESSION_ID/qr" \
|
||||
-H "Authorization: Bearer $API_KEY" | jq .qr
|
||||
```
|
||||
|
||||
Open WhatsApp on your phone → Linked Devices → Link a Device → scan the QR.
|
||||
|
||||
Wait a few seconds, then check the session status:
|
||||
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/sessions/$SESSION_ID/" \
|
||||
-H "Authorization: Bearer $API_KEY" | jq .status
|
||||
# → "CONNECTED"
|
||||
```
|
||||
|
||||
## 9. Send your first message
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/sessions/$SESSION_ID/messages/text" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to":"1234567890@s.whatsapp.net","text":"Hello from Wahoo!"}' | jq .
|
||||
```
|
||||
|
||||
Replace `1234567890` with the recipient's full phone number (country code, no `+`).
|
||||
|
||||
## 10. Explore the API (Swagger UI)
|
||||
|
||||
Open [http://localhost:3000/api/docs](http://localhost:3000/api/docs) in your browser.
|
||||
|
||||
The full interactive API reference is served there — try endpoints directly, view request/response schemas, and authenticate using your `wak_live_...` key via the **Authorize** button.
|
||||
|
||||
The raw OpenAPI spec (OAS 3.0 JSON) is at [http://localhost:3000/api/openapi/json](http://localhost:3000/api/openapi/json).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Read [docs/api.md](./api.md) for the full REST reference
|
||||
- Set up webhooks — see [docs/webhooks.md](./webhooks.md)
|
||||
- Review security practices — see [docs/security.md](./security.md)
|
||||
- Deploy to production — see [docs/deployment.md](./deployment.md)
|
||||
- Understand the risks — see [docs/baileys-risk.md](./baileys-risk.md)
|
||||
4
drizzle/0006_tricky_korg.sql
Normal file
4
drizzle/0006_tricky_korg.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "apps" ADD COLUMN "rotation_status" text;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ADD COLUMN "rotation_job_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ADD COLUMN "rotation_started_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "apps" ADD COLUMN "rotation_completed_at" timestamp;
|
||||
1629
drizzle/meta/0006_snapshot.json
Normal file
1629
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
|||
"when": 1778876071537,
|
||||
"tag": "0005_lush_moondragon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1778918643395,
|
||||
"tag": "0006_tricky_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,6 @@ export default [
|
|||
},
|
||||
},
|
||||
{
|
||||
ignores: ["eslint.config.js", "prettier.config.js", ".output/**"],
|
||||
ignores: ["eslint.config.js", "prettier.config.js", ".output/**", "load-test/**"],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
79
load-test/k6.js
Normal file
79
load-test/k6.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* k6 load test — Wahoo Send Text API
|
||||
*
|
||||
* Usage:
|
||||
* k6 run \
|
||||
* -e BASE_URL=http://localhost:3000 \
|
||||
* -e API_KEY=wak_live_xxx \
|
||||
* -e SESSION_ID=<uuid> \
|
||||
* load-test/k6.js
|
||||
*
|
||||
* Install k6: https://k6.io/docs/get-started/installation/
|
||||
*
|
||||
* The test phone number (+15550000001@s.whatsapp.net) is a dummy recipient.
|
||||
* Replace with a real JID for meaningful delivery testing.
|
||||
*/
|
||||
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
import { Rate } from "k6/metrics";
|
||||
|
||||
const errorRate = new Rate("errors");
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
|
||||
const API_KEY = __ENV.API_KEY || "";
|
||||
const SESSION_ID = __ENV.SESSION_ID || "";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
ramp_up: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "1m", target: 10 },
|
||||
{ duration: "10m", target: 10 },
|
||||
{ duration: "1m", target: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<250"],
|
||||
errors: ["rate<0.01"],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
console.log("=== Wahoo k6 load test ===");
|
||||
console.log(`BASE_URL: ${BASE_URL}`);
|
||||
console.log(`SESSION_ID: ${SESSION_ID}`);
|
||||
console.log(`API_KEY: ${API_KEY.slice(0, 12)}...`);
|
||||
console.log("Scenarios: ramp 0→10 VUs over 1m, hold 10m, ramp down 1m");
|
||||
console.log("Thresholds: p95 < 250ms, error rate < 1%");
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const url = `${BASE_URL}/api/v1/sessions/${SESSION_ID}/messages/text`;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
to: "15550000001@s.whatsapp.net",
|
||||
text: `Load test message from VU ${__VU} iter ${__ITER} at ${Date.now()}`,
|
||||
});
|
||||
|
||||
const params = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const res = http.post(url, payload, params);
|
||||
|
||||
const ok = check(res, {
|
||||
"status is 202": (r) => r.status === 202,
|
||||
"response time < 500ms": (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
errorRate.add(!ok);
|
||||
|
||||
sleep(0.1);
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ export const apps = pgTable("apps", {
|
|||
.references(() => users.id),
|
||||
dekEncrypted: text("dek_encrypted").notNull(),
|
||||
maxSessions: integer("max_sessions").notNull().default(10),
|
||||
rotationStatus: text("rotation_status"),
|
||||
rotationJobId: text("rotation_job_id"),
|
||||
rotationStartedAt: timestamp("rotation_started_at"),
|
||||
rotationCompletedAt: timestamp("rotation_completed_at"),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { db } from "#/db/index";
|
|||
import { apps, apiKeys } from "#/db/schema";
|
||||
import { generateDek, wrapDek } from "#/lib/crypto";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
import { createQueue, QUEUE_KEY_ROTATION } from "#/lib/queue/index";
|
||||
|
||||
type App = typeof apps.$inferSelect;
|
||||
|
||||
|
|
@ -100,6 +101,62 @@ export async function updateUserApp(
|
|||
return updated as App;
|
||||
}
|
||||
|
||||
export async function startKeyRotation(
|
||||
appId: string,
|
||||
userId: string,
|
||||
audit?: { ip?: string; userAgent?: string },
|
||||
): Promise<{ ok: true; jobId: string } | { ok: false; error: string; status: number }> {
|
||||
const app = await findUserApp(appId, userId);
|
||||
if (!app) return { ok: false, error: "Not found", status: 404 };
|
||||
|
||||
if (app.rotationStatus === "pending" || app.rotationStatus === "running") {
|
||||
return { ok: false, error: "Rotation already in progress", status: 409 };
|
||||
}
|
||||
|
||||
const newDek = generateDek();
|
||||
const newDekEncrypted = wrapDek(newDek);
|
||||
|
||||
const queue = createQueue(QUEUE_KEY_ROTATION);
|
||||
const job = await queue.add("rotate", { appId, newDekEncrypted });
|
||||
await queue.close();
|
||||
|
||||
await db
|
||||
.update(apps)
|
||||
.set({
|
||||
rotationStatus: "pending",
|
||||
rotationJobId: String(job.id),
|
||||
rotationStartedAt: new Date(),
|
||||
rotationCompletedAt: null,
|
||||
})
|
||||
.where(eq(apps.id, appId));
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "app.rotation.start",
|
||||
resourceType: "app",
|
||||
resourceId: appId,
|
||||
ip: audit?.ip,
|
||||
userAgent: audit?.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true, jobId: String(job.id) };
|
||||
}
|
||||
|
||||
export async function getKeyRotationStatus(
|
||||
appId: string,
|
||||
userId: string,
|
||||
): Promise<{ status: string | null; startedAt: Date | null; completedAt: Date | null } | null> {
|
||||
const app = await findUserApp(appId, userId);
|
||||
if (!app) return null;
|
||||
|
||||
return {
|
||||
status: app.rotationStatus ?? null,
|
||||
startedAt: app.rotationStartedAt ?? null,
|
||||
completedAt: app.rotationCompletedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteUserApp(
|
||||
appId: string,
|
||||
userId: string,
|
||||
|
|
|
|||
1765
src/lib/openapi.ts
Normal file
1765
src/lib/openapi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@ export const QUEUE_BROADCAST = "msg-broadcast";
|
|||
export const QUEUE_CLEANUP = "msg-cleanup";
|
||||
export const QUEUE_CONTACT_IMPORT = "contact-import";
|
||||
export const QUEUE_WEBHOOK = "webhook-delivery";
|
||||
export const QUEUE_KEY_ROTATION = "key-rotation";
|
||||
|
||||
export function createQueue(name: string): Queue {
|
||||
return new Queue(name, { connection: getRedisConnection() });
|
||||
|
|
|
|||
114
src/lib/queue/rotation-worker.ts
Normal file
114
src/lib/queue/rotation-worker.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { Worker } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createWorker, QUEUE_KEY_ROTATION } from "./index";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, contacts, sessions } from "#/db/schema";
|
||||
import { unwrapDek, encryptColumn, decryptColumn } from "#/lib/crypto";
|
||||
import { phoneBlindIndex } from "#/lib/contacts-crypto";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
||||
export interface KeyRotationJob {
|
||||
appId: string;
|
||||
newDekEncrypted: string;
|
||||
}
|
||||
|
||||
const _g = globalThis as { __wahooRotationWorker?: Worker };
|
||||
|
||||
export function startRotationWorker(): Worker {
|
||||
if (_g.__wahooRotationWorker) return _g.__wahooRotationWorker;
|
||||
|
||||
const worker = createWorker(QUEUE_KEY_ROTATION, async (job) => {
|
||||
const { appId, newDekEncrypted } = job.data as KeyRotationJob;
|
||||
|
||||
await db
|
||||
.update(apps)
|
||||
.set({ rotationStatus: "running" })
|
||||
.where(eq(apps.id, appId));
|
||||
|
||||
try {
|
||||
const appRow = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
columns: { dekEncrypted: true, ownerUserId: true },
|
||||
});
|
||||
if (!appRow) throw new Error(`App ${appId} not found`);
|
||||
|
||||
const oldDek = unwrapDek(appRow.dekEncrypted);
|
||||
const newDek = unwrapDek(newDekEncrypted);
|
||||
|
||||
const contactRows = await db.query.contacts.findMany({
|
||||
where: eq(contacts.appId, appId),
|
||||
columns: {
|
||||
id: true,
|
||||
phoneEnc: true,
|
||||
nameEnc: true,
|
||||
emailEnc: true,
|
||||
customFieldsEnc: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const row of contactRows) {
|
||||
const phone = decryptColumn(row.phoneEnc, oldDek);
|
||||
const updates: Record<string, string | null> = {
|
||||
phoneEnc: encryptColumn(phone, newDek),
|
||||
phoneBlindIndex: phoneBlindIndex(phone, newDek),
|
||||
};
|
||||
|
||||
if (row.nameEnc) {
|
||||
updates.nameEnc = encryptColumn(decryptColumn(row.nameEnc, oldDek), newDek);
|
||||
}
|
||||
if (row.emailEnc) {
|
||||
updates.emailEnc = encryptColumn(decryptColumn(row.emailEnc, oldDek), newDek);
|
||||
}
|
||||
if (row.customFieldsEnc) {
|
||||
updates.customFieldsEnc = encryptColumn(
|
||||
decryptColumn(row.customFieldsEnc, oldDek),
|
||||
newDek,
|
||||
);
|
||||
}
|
||||
|
||||
await db.update(contacts).set(updates).where(eq(contacts.id, row.id));
|
||||
}
|
||||
|
||||
const sessionRows = await db.query.sessions.findMany({
|
||||
where: eq(sessions.appId, appId),
|
||||
columns: { id: true, credentialsEncrypted: true },
|
||||
});
|
||||
|
||||
for (const row of sessionRows) {
|
||||
if (!row.credentialsEncrypted) continue;
|
||||
const plain = decryptColumn(row.credentialsEncrypted, oldDek);
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ credentialsEncrypted: encryptColumn(plain, newDek) })
|
||||
.where(eq(sessions.id, row.id));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(apps)
|
||||
.set({
|
||||
dekEncrypted: newDekEncrypted,
|
||||
rotationStatus: "done",
|
||||
rotationCompletedAt: new Date(),
|
||||
})
|
||||
.where(eq(apps.id, appId));
|
||||
|
||||
await writeAudit({
|
||||
actorType: "user",
|
||||
actorId: appRow.ownerUserId,
|
||||
action: "app.rotation.complete",
|
||||
resourceType: "app",
|
||||
resourceId: appId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[rotation-worker] rotation failed for app", appId, err);
|
||||
await db
|
||||
.update(apps)
|
||||
.set({ rotationStatus: "failed" })
|
||||
.where(eq(apps.id, appId));
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
_g.__wahooRotationWorker = worker;
|
||||
return worker;
|
||||
}
|
||||
|
|
@ -20,7 +20,9 @@ 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 ApiDocsRouteImport } from './routes/api/docs'
|
||||
import { Route as AppsAppIdIndexRouteImport } from './routes/apps/$appId/index'
|
||||
import { Route as ApiOpenapiJsonRouteImport } from './routes/api/openapi.json'
|
||||
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'
|
||||
|
|
@ -87,6 +89,7 @@ import { Route as ApiV1AdminSessionsIdDisconnectRouteImport } from './routes/api
|
|||
import { Route as ApiV1AdminContactsIdTagsRouteImport } from './routes/api/v1/admin/contacts/$id/tags'
|
||||
import { Route as ApiV1AdminAppsIdSyncContactsRouteImport } from './routes/api/v1/admin/apps/$id/sync-contacts'
|
||||
import { Route as ApiV1AdminContactGroupsIdMembersIndexRouteImport } from './routes/api/v1/admin/contact-groups/$id/members/index'
|
||||
import { Route as ApiV1AdminAppsIdRotateEncryptionKeyIndexRouteImport } from './routes/api/v1/admin/apps/$id/rotate-encryption-key/index'
|
||||
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 ApiV1AdminContactGroupsIdMembersContactIdRouteImport } from './routes/api/v1/admin/contact-groups/$id/members/$contactId'
|
||||
|
|
@ -147,11 +150,21 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
|
|||
path: '/api/health',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiDocsRoute = ApiDocsRouteImport.update({
|
||||
id: '/api/docs',
|
||||
path: '/api/docs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppsAppIdIndexRoute = AppsAppIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppsAppIdRoute,
|
||||
} as any)
|
||||
const ApiOpenapiJsonRoute = ApiOpenapiJsonRouteImport.update({
|
||||
id: '/api/openapi/json',
|
||||
path: '/api/openapi/json',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthMeRoute = ApiAuthMeRouteImport.update({
|
||||
id: '/api/auth/me',
|
||||
path: '/api/auth/me',
|
||||
|
|
@ -519,6 +532,12 @@ const ApiV1AdminContactGroupsIdMembersIndexRoute =
|
|||
path: '/api/v1/admin/contact-groups/$id/members/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute =
|
||||
ApiV1AdminAppsIdRotateEncryptionKeyIndexRouteImport.update({
|
||||
id: '/api/v1/admin/apps/$id/rotate-encryption-key/',
|
||||
path: '/api/v1/admin/apps/$id/rotate-encryption-key/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiV1AdminAppsIdApiKeysIndexRoute =
|
||||
ApiV1AdminAppsIdApiKeysIndexRouteImport.update({
|
||||
id: '/api/v1/admin/apps/$id/api-keys/',
|
||||
|
|
@ -549,6 +568,7 @@ export interface FileRoutesByFullPath {
|
|||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/monitoring': typeof MonitoringRoute
|
||||
'/api/docs': typeof ApiDocsRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||
|
|
@ -559,6 +579,7 @@ export interface FileRoutesByFullPath {
|
|||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/api/openapi/json': typeof ApiOpenapiJsonRoute
|
||||
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
|
|
@ -626,6 +647,7 @@ export interface FileRoutesByFullPath {
|
|||
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
'/api/v1/admin/apps/$id/rotate-encryption-key/': typeof ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute
|
||||
'/api/v1/admin/contact-groups/$id/members/': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
|
|
@ -633,6 +655,7 @@ export interface FileRoutesByTo {
|
|||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/monitoring': typeof MonitoringRoute
|
||||
'/api/docs': typeof ApiDocsRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/sessions/$id': typeof SessionsIdRoute
|
||||
|
|
@ -642,6 +665,7 @@ export interface FileRoutesByTo {
|
|||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/api/openapi/json': typeof ApiOpenapiJsonRoute
|
||||
'/apps/$appId': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
|
|
@ -709,6 +733,7 @@ export interface FileRoutesByTo {
|
|||
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
'/api/v1/admin/apps/$id/rotate-encryption-key': typeof ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute
|
||||
'/api/v1/admin/contact-groups/$id/members': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
|
|
@ -717,6 +742,7 @@ export interface FileRoutesById {
|
|||
'/audit-log': typeof AuditLogRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/monitoring': typeof MonitoringRoute
|
||||
'/api/docs': typeof ApiDocsRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/ready': typeof ApiReadyRoute
|
||||
'/apps/$appId': typeof AppsAppIdRouteWithChildren
|
||||
|
|
@ -727,6 +753,7 @@ export interface FileRoutesById {
|
|||
'/api/auth/login': typeof ApiAuthLoginRoute
|
||||
'/api/auth/logout': typeof ApiAuthLogoutRoute
|
||||
'/api/auth/me': typeof ApiAuthMeRoute
|
||||
'/api/openapi/json': typeof ApiOpenapiJsonRoute
|
||||
'/apps/$appId/': typeof AppsAppIdIndexRoute
|
||||
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
|
||||
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
|
||||
|
|
@ -794,6 +821,7 @@ export interface FileRoutesById {
|
|||
'/api/v1/admin/contact-groups/$id/members/$contactId': typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
|
||||
'/api/v1/sessions/$id/messages/$msgId/read': typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
'/api/v1/admin/apps/$id/rotate-encryption-key/': typeof ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute
|
||||
'/api/v1/admin/contact-groups/$id/members/': typeof ApiV1AdminContactGroupsIdMembersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
|
|
@ -803,6 +831,7 @@ export interface FileRouteTypes {
|
|||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/monitoring'
|
||||
| '/api/docs'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/apps/$appId'
|
||||
|
|
@ -813,6 +842,7 @@ export interface FileRouteTypes {
|
|||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/api/openapi/json'
|
||||
| '/apps/$appId/'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
|
|
@ -880,6 +910,7 @@ export interface FileRouteTypes {
|
|||
| '/api/v1/admin/contact-groups/$id/members/$contactId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys/'
|
||||
| '/api/v1/admin/apps/$id/rotate-encryption-key/'
|
||||
| '/api/v1/admin/contact-groups/$id/members/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
|
|
@ -887,6 +918,7 @@ export interface FileRouteTypes {
|
|||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/monitoring'
|
||||
| '/api/docs'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/sessions/$id'
|
||||
|
|
@ -896,6 +928,7 @@ export interface FileRouteTypes {
|
|||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/api/openapi/json'
|
||||
| '/apps/$appId'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
|
|
@ -963,6 +996,7 @@ export interface FileRouteTypes {
|
|||
| '/api/v1/admin/contact-groups/$id/members/$contactId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys'
|
||||
| '/api/v1/admin/apps/$id/rotate-encryption-key'
|
||||
| '/api/v1/admin/contact-groups/$id/members'
|
||||
id:
|
||||
| '__root__'
|
||||
|
|
@ -970,6 +1004,7 @@ export interface FileRouteTypes {
|
|||
| '/audit-log'
|
||||
| '/login'
|
||||
| '/monitoring'
|
||||
| '/api/docs'
|
||||
| '/api/health'
|
||||
| '/api/ready'
|
||||
| '/apps/$appId'
|
||||
|
|
@ -980,6 +1015,7 @@ export interface FileRouteTypes {
|
|||
| '/api/auth/login'
|
||||
| '/api/auth/logout'
|
||||
| '/api/auth/me'
|
||||
| '/api/openapi/json'
|
||||
| '/apps/$appId/'
|
||||
| '/api/auth/totp/enroll'
|
||||
| '/api/v1/admin/audit-log'
|
||||
|
|
@ -1047,6 +1083,7 @@ export interface FileRouteTypes {
|
|||
| '/api/v1/admin/contact-groups/$id/members/$contactId'
|
||||
| '/api/v1/sessions/$id/messages/$msgId/read'
|
||||
| '/api/v1/admin/apps/$id/api-keys/'
|
||||
| '/api/v1/admin/apps/$id/rotate-encryption-key/'
|
||||
| '/api/v1/admin/contact-groups/$id/members/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
|
@ -1055,6 +1092,7 @@ export interface RootRouteChildren {
|
|||
AuditLogRoute: typeof AuditLogRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
MonitoringRoute: typeof MonitoringRoute
|
||||
ApiDocsRoute: typeof ApiDocsRoute
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiReadyRoute: typeof ApiReadyRoute
|
||||
AppsAppIdRoute: typeof AppsAppIdRouteWithChildren
|
||||
|
|
@ -1065,6 +1103,7 @@ export interface RootRouteChildren {
|
|||
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
|
||||
ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute
|
||||
ApiAuthMeRoute: typeof ApiAuthMeRoute
|
||||
ApiOpenapiJsonRoute: typeof ApiOpenapiJsonRoute
|
||||
ApiAuthTotpEnrollRoute: typeof ApiAuthTotpEnrollRoute
|
||||
ApiV1AdminAuditLogRoute: typeof ApiV1AdminAuditLogRoute
|
||||
ApiV1CustomFieldSchemasIdRoute: typeof ApiV1CustomFieldSchemasIdRoute
|
||||
|
|
@ -1121,6 +1160,7 @@ export interface RootRouteChildren {
|
|||
ApiV1AdminContactGroupsIdMembersContactIdRoute: typeof ApiV1AdminContactGroupsIdMembersContactIdRoute
|
||||
ApiV1SessionsIdMessagesMsgIdReadRoute: typeof ApiV1SessionsIdMessagesMsgIdReadRoute
|
||||
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
|
||||
ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute: typeof ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute
|
||||
ApiV1AdminContactGroupsIdMembersIndexRoute: typeof ApiV1AdminContactGroupsIdMembersIndexRoute
|
||||
}
|
||||
|
||||
|
|
@ -1203,6 +1243,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ApiHealthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/docs': {
|
||||
id: '/api/docs'
|
||||
path: '/api/docs'
|
||||
fullPath: '/api/docs'
|
||||
preLoaderRoute: typeof ApiDocsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/apps/$appId/': {
|
||||
id: '/apps/$appId/'
|
||||
path: '/'
|
||||
|
|
@ -1210,6 +1257,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AppsAppIdIndexRouteImport
|
||||
parentRoute: typeof AppsAppIdRoute
|
||||
}
|
||||
'/api/openapi/json': {
|
||||
id: '/api/openapi/json'
|
||||
path: '/api/openapi/json'
|
||||
fullPath: '/api/openapi/json'
|
||||
preLoaderRoute: typeof ApiOpenapiJsonRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/me': {
|
||||
id: '/api/auth/me'
|
||||
path: '/api/auth/me'
|
||||
|
|
@ -1672,6 +1726,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ApiV1AdminContactGroupsIdMembersIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/v1/admin/apps/$id/rotate-encryption-key/': {
|
||||
id: '/api/v1/admin/apps/$id/rotate-encryption-key/'
|
||||
path: '/api/v1/admin/apps/$id/rotate-encryption-key'
|
||||
fullPath: '/api/v1/admin/apps/$id/rotate-encryption-key/'
|
||||
preLoaderRoute: typeof ApiV1AdminAppsIdRotateEncryptionKeyIndexRouteImport
|
||||
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'
|
||||
|
|
@ -1741,6 +1802,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
AuditLogRoute: AuditLogRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
MonitoringRoute: MonitoringRoute,
|
||||
ApiDocsRoute: ApiDocsRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiReadyRoute: ApiReadyRoute,
|
||||
AppsAppIdRoute: AppsAppIdRouteWithChildren,
|
||||
|
|
@ -1751,6 +1813,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
ApiAuthLoginRoute: ApiAuthLoginRoute,
|
||||
ApiAuthLogoutRoute: ApiAuthLogoutRoute,
|
||||
ApiAuthMeRoute: ApiAuthMeRoute,
|
||||
ApiOpenapiJsonRoute: ApiOpenapiJsonRoute,
|
||||
ApiAuthTotpEnrollRoute: ApiAuthTotpEnrollRoute,
|
||||
ApiV1AdminAuditLogRoute: ApiV1AdminAuditLogRoute,
|
||||
ApiV1CustomFieldSchemasIdRoute: ApiV1CustomFieldSchemasIdRoute,
|
||||
|
|
@ -1811,6 +1874,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
ApiV1AdminContactGroupsIdMembersContactIdRoute,
|
||||
ApiV1SessionsIdMessagesMsgIdReadRoute: ApiV1SessionsIdMessagesMsgIdReadRoute,
|
||||
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,
|
||||
ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute:
|
||||
ApiV1AdminAppsIdRotateEncryptionKeyIndexRoute,
|
||||
ApiV1AdminContactGroupsIdMembersIndexRoute:
|
||||
ApiV1AdminContactGroupsIdMembersIndexRoute,
|
||||
}
|
||||
|
|
|
|||
37
src/routes/api/docs.ts
Normal file
37
src/routes/api/docs.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Wahoo API Docs</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" >
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"> </script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: "/api/openapi/json",
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true,
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const Route = createFileRoute("/api/docs")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () =>
|
||||
new Response(html, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
14
src/routes/api/openapi.json.ts
Normal file
14
src/routes/api/openapi.json.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { spec } from "#/lib/openapi";
|
||||
|
||||
export const Route = createFileRoute("/api/openapi/json")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () =>
|
||||
new Response(JSON.stringify(spec), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json, jsonError, readSession } from "#/lib/session-api";
|
||||
import { startKeyRotation, getKeyRotationStatus } from "#/lib/actions/apps";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/api/v1/admin/apps/$id/rotate-encryption-key/",
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const result = await startKeyRotation(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(result, 202);
|
||||
},
|
||||
|
||||
GET: async ({ request, params }) => {
|
||||
const session = await readSession(request);
|
||||
if (!session?.userId) return jsonError("Unauthorized", 401);
|
||||
|
||||
const status = await getKeyRotationStatus(params.id, session.userId);
|
||||
if (!status) return jsonError("Not found", 404);
|
||||
return json(status);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Key, Plus, Copy, Check, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Key, Plus, Copy, Check, Trash2, AlertTriangle, Shield, RefreshCw } from "lucide-react";
|
||||
import { appQuery, apiKeysQuery } from "#/lib/query";
|
||||
import { Button } from "#/components/ui/button";
|
||||
import { Input } from "#/components/ui/input";
|
||||
|
|
@ -47,6 +47,60 @@ function AppKeysPage() {
|
|||
const [generating, setGenerating] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [rotateOpen, setRotateOpen] = useState(false);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [rotateError, setRotateError] = useState("");
|
||||
const [rotationStatus, setRotationStatus] = useState<{
|
||||
status: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
} | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function fetchRotationStatus() {
|
||||
const res = await fetch(
|
||||
`/api/v1/admin/apps/${appId}/rotate-encryption-key`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRotationStatus(data);
|
||||
if (data.status === "done" || data.status === "failed") {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRotation() {
|
||||
setRotateError("");
|
||||
setRotating(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/admin/apps/${appId}/rotate-encryption-key`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setRotateError(data.error ?? "Failed to start rotation");
|
||||
return;
|
||||
}
|
||||
setRotateOpen(false);
|
||||
await fetchRotationStatus();
|
||||
pollRef.current = setInterval(fetchRotationStatus, 3000);
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateKey(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
|
@ -253,6 +307,58 @@ function AppKeysPage() {
|
|||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Security */}
|
||||
<section>
|
||||
<h2 className="text-[13px] font-semibold text-gray-900 mb-3 flex items-center gap-1.5">
|
||||
<Shield size={13} strokeWidth={2} />
|
||||
Security
|
||||
</h2>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-gray-900">
|
||||
Rotate encryption key
|
||||
</p>
|
||||
<p className="text-[12px] text-gray-500 mt-0.5">
|
||||
Generate a new data encryption key and re-encrypt all contact and session data. This may take a moment.
|
||||
</p>
|
||||
{rotationStatus && (
|
||||
<div className="mt-2">
|
||||
{rotationStatus.status === "pending" || rotationStatus.status === "running" ? (
|
||||
<div className="flex items-center gap-2 text-[12px] text-blue-600">
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
Rotation in progress…
|
||||
</div>
|
||||
) : rotationStatus.status === "done" ? (
|
||||
<p className="text-[12px] text-emerald-600">
|
||||
Rotation completed{rotationStatus.completedAt ? ` at ${new Date(rotationStatus.completedAt).toLocaleString()}` : ""}.
|
||||
</p>
|
||||
) : rotationStatus.status === "failed" ? (
|
||||
<p className="text-[12px] text-red-600">Rotation failed. Please try again.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRotateOpen(true);
|
||||
setRotateError("");
|
||||
}}
|
||||
disabled={
|
||||
rotationStatus?.status === "pending" ||
|
||||
rotationStatus?.status === "running"
|
||||
}
|
||||
className="h-8 px-3 text-[13px] flex-shrink-0"
|
||||
>
|
||||
<RefreshCw size={12} strokeWidth={2} className="mr-1.5" />
|
||||
Rotate key
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Danger zone */}
|
||||
<section>
|
||||
<h2 className="text-[13px] font-semibold text-red-500 mb-3 flex items-center gap-1.5">
|
||||
|
|
@ -347,6 +453,48 @@ function AppKeysPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rotate Key Dialog */}
|
||||
<Dialog
|
||||
open={rotateOpen}
|
||||
onOpenChange={(o) => {
|
||||
setRotateOpen(o);
|
||||
if (!o) setRotateError("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">Rotate encryption key?</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
This generates a new data encryption key and re-encrypts all contacts and session credentials. The app remains operational. This operation runs in the background and may take a few minutes for large datasets.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{rotateError && (
|
||||
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
{rotateError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRotateOpen(false)}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startRotation}
|
||||
disabled={rotating}
|
||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||
>
|
||||
{rotating ? "Starting…" : "Rotate key"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete App Dialog */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
|
|
|
|||
|
|
@ -309,6 +309,11 @@ function WebhooksPage() {
|
|||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
Warning: HTTP endpoints receive webhook payloads unencrypted. Use HTTPS in production.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Events (empty = all)</Label>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { startBroadcastWorker } from "#/lib/queue/broadcast-worker";
|
|||
import { startCleanupWorker } from "#/lib/queue/cleanup-worker";
|
||||
import { startImportWorker } from "#/lib/queue/import-worker";
|
||||
import { startWebhookWorker } from "#/lib/queue/webhook-worker";
|
||||
import { startRotationWorker } from "#/lib/queue/rotation-worker";
|
||||
import { validateMasterKey } from "#/lib/crypto";
|
||||
import type { Worker } from "bullmq";
|
||||
|
||||
const _g = globalThis as {
|
||||
|
|
@ -12,10 +14,18 @@ const _g = globalThis as {
|
|||
__wahooCleanupWorker?: Worker;
|
||||
__wahooImportWorker?: Worker;
|
||||
__wahooWebhookWorker?: Worker;
|
||||
__wahooRotationWorker?: Worker;
|
||||
__wahooShutdownRegistered?: boolean;
|
||||
};
|
||||
|
||||
export default async function () {
|
||||
try {
|
||||
validateMasterKey();
|
||||
} catch (err) {
|
||||
console.error("[startup] FATAL:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await initialize();
|
||||
console.log("[session-startup] sessions initialized");
|
||||
|
|
@ -29,6 +39,7 @@ export default async function () {
|
|||
await startCleanupWorker();
|
||||
startImportWorker();
|
||||
startWebhookWorker();
|
||||
startRotationWorker();
|
||||
console.log("[session-startup] queue workers started");
|
||||
} catch (err) {
|
||||
console.error("[session-startup] failed to start queue workers:", err);
|
||||
|
|
@ -44,6 +55,7 @@ export default async function () {
|
|||
_g.__wahooCleanupWorker?.close(),
|
||||
_g.__wahooImportWorker?.close(),
|
||||
_g.__wahooWebhookWorker?.close(),
|
||||
_g.__wahooRotationWorker?.close(),
|
||||
]);
|
||||
process.exit(0);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue