Compare commits
10 commits
5776146c50
...
0082802bac
| Author | SHA1 | Date | |
|---|---|---|---|
| 0082802bac | |||
| e634ffddf2 | |||
| 3bbe2ac108 | |||
| 46567538dc | |||
| c37f8f77c2 | |||
| 0aef5bca86 | |||
| 8a84a660b3 | |||
| 8a8420c4e3 | |||
| b08546294a | |||
| 7af75b6bb6 |
133 changed files with 14694 additions and 298 deletions
10
.cta.json
10
.cta.json
|
|
@ -16,11 +16,5 @@
|
||||||
"routerOnly": false,
|
"routerOnly": false,
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"framework": "react",
|
"framework": "react",
|
||||||
"chosenAddOns": [
|
"chosenAddOns": ["eslint", "nitro", "shadcn", "drizzle", "table"]
|
||||||
"eslint",
|
}
|
||||||
"nitro",
|
|
||||||
"shadcn",
|
|
||||||
"drizzle",
|
|
||||||
"table"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
drizzle
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
.playground
|
||||||
|
docs
|
||||||
22
.env.example
Normal file
22
.env.example
Normal 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
3
.gitignore
vendored
|
|
@ -11,3 +11,6 @@ dist-ssr
|
||||||
.vinxi
|
.vinxi
|
||||||
__unconfig*
|
__unconfig*
|
||||||
todos.json
|
todos.json
|
||||||
|
.playground
|
||||||
|
test-results
|
||||||
|
uploads
|
||||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
121
CLAUDE.md
Normal file
121
CLAUDE.md
Normal 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
23
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
||||||
61
README.md
61
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
Welcome to your new TanStack Start app!
|
Welcome to your new TanStack Start app!
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
|
|
@ -40,7 +40,6 @@ If you prefer not to use Tailwind CSS:
|
||||||
|
|
||||||
## Linting & Formatting
|
## 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:
|
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
|
```bash
|
||||||
|
|
@ -49,7 +48,6 @@ bun --bun run format
|
||||||
bun --bun run check
|
bun --bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Deploy with Nitro
|
## Deploy with Nitro
|
||||||
|
|
||||||
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
|
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.
|
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
|
||||||
|
|
||||||
|
|
||||||
## Shadcn
|
## Shadcn
|
||||||
|
|
||||||
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
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
|
pnpm dlx shadcn@latest add button
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
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:
|
Here is an example layout that includes a header:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ charSet: 'utf-8' },
|
{ charSet: "utf-8" },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
{ title: 'My App' },
|
{ title: "My App" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: ({ children }) => (
|
shellComponent: ({ children }) => (
|
||||||
|
|
@ -138,7 +133,7 @@ export const Route = createRootRoute({
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
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.
|
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
const getServerTime = createServerFn({
|
const getServerTime = createServerFn({
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
}).handler(async () => {
|
}).handler(async () => {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Use in a component
|
// Use in a component
|
||||||
function MyComponent() {
|
function MyComponent() {
|
||||||
const [time, setTime] = useState('')
|
const [time, setTime] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getServerTime().then(setTime)
|
getServerTime().then(setTime);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return <div>Server time: {time}</div>
|
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:
|
You can create API routes by using the `server` property in your route definitions:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { json } from '@tanstack/react-start'
|
import { json } from "@tanstack/react-start";
|
||||||
|
|
||||||
export const Route = createFileRoute('/api/hello')({
|
export const Route = createFileRoute("/api/hello")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: () => json({ message: 'Hello, World!' }),
|
GET: () => json({ message: "Hello, World!" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Fetching
|
## Data Fetching
|
||||||
|
|
@ -192,25 +187,25 @@ There are multiple ways to fetch data in your application. You can use TanStack
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```tsx
|
```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 () => {
|
loader: async () => {
|
||||||
const response = await fetch('https://swapi.dev/api/people')
|
const response = await fetch("https://swapi.dev/api/people");
|
||||||
return response.json()
|
return response.json();
|
||||||
},
|
},
|
||||||
component: PeopleComponent,
|
component: PeopleComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PeopleComponent() {
|
function PeopleComponent() {
|
||||||
const data = Route.useLoaderData()
|
const data = Route.useLoaderData();
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
{data.results.map((person) => (
|
{data.results.map((person) => (
|
||||||
<li key={person.name}>{person.name}</li>
|
<li key={person.name}>{person.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@
|
||||||
"hooks": "#/hooks"
|
"hooks": "#/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal 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
91
docs/api.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# API Reference
|
||||||
|
|
||||||
|
Full interactive spec: `GET /openapi.json` → render at `GET /docs` (available after M6).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All API endpoints require:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer wak_<env>_<32-char-random>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys are per-App. All requests scoped to the authenticating App — no cross-app access.
|
||||||
|
|
||||||
|
## Rate limits
|
||||||
|
|
||||||
|
Default: 60 req/s burst, 10 req/s sustained per API key. Configurable. Returns `429` with `Retry-After` header on exceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------------------- | ------------------------------------ |
|
||||||
|
| POST | `/v1/sessions` | Create session |
|
||||||
|
| GET | `/v1/sessions` | List sessions |
|
||||||
|
| GET | `/v1/sessions/:id` | Session detail + status |
|
||||||
|
| DELETE | `/v1/sessions/:id` | Delete + wipe credentials |
|
||||||
|
| POST | `/v1/sessions/:id/disconnect` | Graceful disconnect |
|
||||||
|
| GET | `/v1/sessions/:id/qr` | Stream QR code (SSE) |
|
||||||
|
| POST | `/v1/sessions/:id/pair` | Request pairing code `{phone}` |
|
||||||
|
| GET | `/v1/sessions/:id/health` | Status + last_seen + reconnect count |
|
||||||
|
|
||||||
|
**Session states:** `PENDING_QR` → `CONNECTING` → `CONNECTED` → `DISCONNECTED` → `DEAD` / `LOGGED_OUT`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------------------------------- | ------------------------------------- |
|
||||||
|
| POST | `/v1/sessions/:id/messages/text` | Send text `{to, text}` |
|
||||||
|
| POST | `/v1/sessions/:id/messages/image` | Send image (multipart or `{to, url}`) |
|
||||||
|
| POST | `/v1/sessions/:id/messages/video` | Send video |
|
||||||
|
| POST | `/v1/sessions/:id/messages/document` | Send document `{to, url, filename}` |
|
||||||
|
| POST | `/v1/sessions/:id/messages/audio` | Send audio `{to, url, voice_note?}` |
|
||||||
|
| POST | `/v1/sessions/:id/messages/broadcast` | Fan-out to multiple JIDs |
|
||||||
|
| POST | `/v1/sessions/:id/presence` | Set presence `{to_jid, type}` |
|
||||||
|
| POST | `/v1/sessions/:id/messages/:msgId/read` | Mark read |
|
||||||
|
| GET | `/v1/sessions/:id/messages` | List `?since=&limit=&direction=` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ---------------- | ------------------------------------------- | -------------------------------------- |
|
||||||
|
| GET | `/v1/contacts` | List `?q=&tag=&group_id=` |
|
||||||
|
| POST | `/v1/contacts` | Create |
|
||||||
|
| GET | `/v1/contacts/:id` | Detail |
|
||||||
|
| PATCH | `/v1/contacts/:id` | Update |
|
||||||
|
| DELETE | `/v1/contacts/:id` | Delete |
|
||||||
|
| POST | `/v1/contacts/import` | CSV import (multipart) `?dry_run=true` |
|
||||||
|
| GET | `/v1/contacts/import/:jobId` | Import job status |
|
||||||
|
| POST | `/v1/contacts/sync-from-whatsapp` | Trigger WhatsApp address book sync |
|
||||||
|
| GET | `/v1/contact-groups` | List groups |
|
||||||
|
| POST | `/v1/contact-groups` | Create group |
|
||||||
|
| GET/PATCH/DELETE | `/v1/contact-groups/:id` | Group CRUD |
|
||||||
|
| POST | `/v1/contact-groups/:id/members` | Bulk add contacts |
|
||||||
|
| DELETE | `/v1/contact-groups/:id/members/:contactId` | Remove member |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ---------------- | ----------------------------- | ----------------------------------- |
|
||||||
|
| GET | `/v1/webhooks` | List endpoints |
|
||||||
|
| POST | `/v1/webhooks` | Register `{url, events[], secret?}` |
|
||||||
|
| GET/PATCH/DELETE | `/v1/webhooks/:id` | Endpoint CRUD |
|
||||||
|
| GET | `/v1/webhooks/:id/deliveries` | Delivery log (last 100) |
|
||||||
|
| POST | `/v1/webhooks/:id/test` | Fire test payload |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------- | ---------------------- |
|
||||||
|
| GET | `/health` | Liveness |
|
||||||
|
| GET | `/ready` | Readiness (DB + Redis) |
|
||||||
60
docs/architecture.md
Normal file
60
docs/architecture.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Stack:** Node.js (Fastify) API + TanStack Start dashboard + PostgreSQL + Redis (BullMQ) + Baileys
|
||||||
|
|
||||||
|
```
|
||||||
|
Consumer App
|
||||||
|
│ Authorization: Bearer wak_*
|
||||||
|
▼
|
||||||
|
Fastify REST API ──────────────────────────┐
|
||||||
|
│ │
|
||||||
|
├── Session Manager (Baileys) │
|
||||||
|
│ ├── QR / pairing onboarding │
|
||||||
|
│ ├── Encrypted creds in PG │
|
||||||
|
│ └── Auto-reconnect │
|
||||||
|
│ │
|
||||||
|
├── BullMQ workers │
|
||||||
|
│ ├── send-queue │
|
||||||
|
│ └── webhook-delivery-queue │
|
||||||
|
│ ▼
|
||||||
|
└── PostgreSQL Redis 7+
|
||||||
|
(Drizzle ORM) (BullMQ + cache)
|
||||||
|
|
||||||
|
TanStack Start Dashboard (Platform Operator only)
|
||||||
|
├── /apps – tenant management
|
||||||
|
├── /sessions – session lifecycle + QR UI
|
||||||
|
├── /contacts – contact book + CSV import
|
||||||
|
├── /webhooks – endpoint CRUD + delivery log
|
||||||
|
├── /monitoring – charts (from PG aggregates + Redis)
|
||||||
|
└── /audit-log – append-only admin actions
|
||||||
|
|
||||||
|
Media Storage (pluggable)
|
||||||
|
├── local – STORAGE_LOCAL_PATH, served via Fastify static
|
||||||
|
└── s3 – MinIO / R2 / AWS S3 via presigned URLs
|
||||||
|
|
||||||
|
Observability
|
||||||
|
└── OpenTelemetry SDK → OTLP/HTTP → external collector
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-tenancy
|
||||||
|
|
||||||
|
Every data row (except `users` and `apps`) carries `app_id`. API key auth resolves `app_id` at request time. No cross-app reads.
|
||||||
|
|
||||||
|
## Encryption model
|
||||||
|
|
||||||
|
- **KEK** — `MASTER_ENCRYPTION_KEY` (env, base64 32 bytes). Never leaves host.
|
||||||
|
- **DEK** — one per App, generated at creation, AES-256-GCM-wrapped by KEK, stored in `apps.dek_encrypted`.
|
||||||
|
- **Encrypted columns** — session credentials, contact PII (name/phone/email/custom_fields), webhook secrets — all encrypted with App DEK.
|
||||||
|
- **Blind index** — phone lookup uses `HMAC-SHA256(e164, index_key)` stored in `contacts.phone_blind_index`.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| ---------------------------- | -------------------------------------------- |
|
||||||
|
| `src/db/schema.ts` | Drizzle schema (single source of truth) |
|
||||||
|
| `src/lib/crypto.ts` | AES-256-GCM helpers, DEK wrap/unwrap |
|
||||||
|
| `src/lib/blindIndex.ts` | HMAC blind index for phone lookups |
|
||||||
|
| `src/lib/baileys/` | Baileys wrapper + session manager |
|
||||||
|
| `src/lib/storage/` | Pluggable media storage drivers |
|
||||||
|
| `src/lib/events/dispatch.ts` | Event → webhook fan-out |
|
||||||
|
| `src/routes/` | TanStack Start file-based routes (dashboard) |
|
||||||
93
docs/deployment.md
Normal file
93
docs/deployment.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker + Docker Compose v2
|
||||||
|
- A server with ≥ 2 vCPU, ≥ 4 GB RAM
|
||||||
|
- A domain name (for TLS)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd wahoo
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — minimum required vars below
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required environment variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://wahoo:secret@postgres:5432/wahoo
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# Generate: openssl rand -base64 32
|
||||||
|
MASTER_ENCRYPTION_KEY=<base64 32 bytes>
|
||||||
|
|
||||||
|
# local or s3
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
STORAGE_LOCAL_PATH=/data/media
|
||||||
|
|
||||||
|
# For s3 driver:
|
||||||
|
# STORAGE_S3_ENDPOINT=https://...
|
||||||
|
# STORAGE_S3_BUCKET=wahoo-media
|
||||||
|
# STORAGE_S3_ACCESS_KEY=...
|
||||||
|
# STORAGE_S3_SECRET_KEY=...
|
||||||
|
|
||||||
|
DASHBOARD_BASE_URL=https://your-domain.example.com
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
MAX_SESSIONS_PER_APP=25
|
||||||
|
WEBHOOK_LOG_RETAIN=100
|
||||||
|
MEDIA_TTL_DAYS=30
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT= # leave empty to disable
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning:** `MASTER_ENCRYPTION_KEY` is the root of all encryption. If lost, all session credentials and PII are unrecoverable. Back it up to a secrets manager before first use.
|
||||||
|
|
||||||
|
## TLS / reverse proxy
|
||||||
|
|
||||||
|
TLS termination is expected at your reverse proxy. Caddy example:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
your-domain.example.com {
|
||||||
|
reverse_proxy api:3001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup procedure
|
||||||
|
|
||||||
|
Back up all three:
|
||||||
|
|
||||||
|
1. **PostgreSQL dump** — `pg_dump $DATABASE_URL > backup.sql`
|
||||||
|
2. **Media storage** — `rsync -av /data/media/ backup/media/` (or S3 snapshot)
|
||||||
|
3. **Master key** — `MASTER_ENCRYPTION_KEY` from `.env` stored in a secrets manager
|
||||||
|
|
||||||
|
## Restore to new host
|
||||||
|
|
||||||
|
1. Provision new host, install Docker.
|
||||||
|
2. Clone repo, copy `.env` with original `MASTER_ENCRYPTION_KEY`.
|
||||||
|
3. Restore Postgres: `psql $DATABASE_URL < backup.sql`
|
||||||
|
4. Restore media to `STORAGE_LOCAL_PATH` (or point to S3 bucket).
|
||||||
|
5. `docker compose up -d` — all sessions resume automatically.
|
||||||
|
|
||||||
|
## Encryption key rotation
|
||||||
|
|
||||||
|
1. Ensure Postgres + media are backed up.
|
||||||
|
2. Dashboard → App → "Rotate Encryption Key".
|
||||||
|
3. Monitor rotation job progress in dashboard.
|
||||||
|
4. After completion, old DEK is destroyed.
|
||||||
|
|
||||||
|
> Do not interrupt a rotation job in progress.
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
bun --bun run db:migrate # inside api container or locally
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin Baileys version in `package.json` — do not auto-upgrade. Check release notes for protocol changes before upgrading.
|
||||||
64
docs/security.md
Normal file
64
docs/security.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Security Model
|
||||||
|
|
||||||
|
## Encryption at rest
|
||||||
|
|
||||||
|
All sensitive data encrypted with AES-256-GCM before writing to PostgreSQL.
|
||||||
|
|
||||||
|
**Key hierarchy:**
|
||||||
|
|
||||||
|
```
|
||||||
|
MASTER_ENCRYPTION_KEY (KEK) ← env var, base64 32 bytes
|
||||||
|
└── wraps App DEK (one per App)
|
||||||
|
├── session credentials
|
||||||
|
├── contact PII (name, phone, email, custom_fields)
|
||||||
|
└── webhook signing secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phone lookup:** `HMAC-SHA256(e164_phone, index_key)` stored as blind index. Enables exact-match search without decrypting the phone column.
|
||||||
|
|
||||||
|
## API authentication
|
||||||
|
|
||||||
|
- Header: `Authorization: Bearer wak_<env>_<32-char-random>`
|
||||||
|
- Random portion stored as Argon2id hash — plaintext never persisted after creation.
|
||||||
|
- Multiple active keys per App for zero-downtime rotation.
|
||||||
|
- Rate limit: 60 req/s burst / 10 req/s sustained (configurable).
|
||||||
|
|
||||||
|
## Dashboard authentication
|
||||||
|
|
||||||
|
- Argon2id password hashing.
|
||||||
|
- Optional TOTP 2FA.
|
||||||
|
- Session cookies: HttpOnly, Secure, SameSite=Lax.
|
||||||
|
|
||||||
|
## Webhook payload integrity
|
||||||
|
|
||||||
|
Every delivery signed: `HMAC-SHA256(raw_body + "." + timestamp, endpoint_secret)`.
|
||||||
|
Header: `X-Signature: sha256=<hex>`.
|
||||||
|
|
||||||
|
Verify (Node.js):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const crypto = require("crypto");
|
||||||
|
function verify(secret, body, timestamp, sig) {
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(body + "." + timestamp)
|
||||||
|
.digest("hex");
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key rotation
|
||||||
|
|
||||||
|
Admin action `POST /v1/admin/apps/:id/rotate-encryption-key` re-encrypts all app data with a new DEK in a background job. Old DEK destroyed after job completes. See `docs/deployment.md` for backup procedure — **back up master key and Postgres before rotating**.
|
||||||
|
|
||||||
|
## Network isolation
|
||||||
|
|
||||||
|
All inter-service traffic stays on Docker network. Only API + dashboard ports exposed externally. TLS termination at reverse proxy (Caddy / Traefik / nginx).
|
||||||
|
|
||||||
|
## Secrets never logged
|
||||||
|
|
||||||
|
Structured logger uses an explicit field allowlist — no API keys, DEKs, or signing secrets appear in logs (SEC-6).
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
Append-only `audit_log` table. Captured actions: login, App CRUD, ApiKey create/rotate/revoke, Session create/delete, webhook CRUD, encryption key rotation, contact bulk ops, config changes.
|
||||||
158
docs/webhooks.md
Normal file
158
docs/webhooks.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# Webhooks
|
||||||
|
|
||||||
|
## Registering an endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/webhooks
|
||||||
|
Authorization: Bearer wak_prod_...
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "https://your-app.example.com/hooks/whatsapp",
|
||||||
|
"events": ["message.received", "session.status"],
|
||||||
|
"secret": "optional-custom-secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `secret` is omitted, one is generated. **Store it — shown once.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event types
|
||||||
|
|
||||||
|
| Event | When fired |
|
||||||
|
| ------------------- | ------------------------------------- |
|
||||||
|
| `session.status` | Any session state transition |
|
||||||
|
| `session.deleted` | Session deleted via API |
|
||||||
|
| `message.received` | Inbound message |
|
||||||
|
| `message.sent` | Outbound message accepted by WhatsApp |
|
||||||
|
| `message.delivered` | WhatsApp delivery receipt received |
|
||||||
|
| `message.read` | Read receipt received |
|
||||||
|
| `message.failed` | Send failed |
|
||||||
|
| `presence.update` | Contact presence change |
|
||||||
|
| `contact.created` | New contact added |
|
||||||
|
| `contact.updated` | Contact modified |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Payload structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "message.received",
|
||||||
|
"event_id": "evt_01hw...",
|
||||||
|
"app_id": "app_01hw...",
|
||||||
|
"timestamp": "2026-05-14T10:00:00Z",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` shape varies per event type — see `/docs` (OpenAPI) for full schemas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard headers
|
||||||
|
|
||||||
|
Every delivery includes:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Event-Id: evt_01hw...
|
||||||
|
X-Event-Type: message.received
|
||||||
|
X-App-Id: app_01hw...
|
||||||
|
X-Signature: sha256=<hex>
|
||||||
|
X-Timestamp: 1715684400
|
||||||
|
User-Agent: wagateway/1.0
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifying signatures
|
||||||
|
|
||||||
|
Signature covers: `raw_body + "." + X-Timestamp`.
|
||||||
|
|
||||||
|
**Node.js**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
function verifyWebhook(secret, rawBody, timestamp, signature) {
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(rawBody + "." + timestamp)
|
||||||
|
.digest("hex");
|
||||||
|
const received = signature.replace("sha256=", "");
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(expected, "hex"),
|
||||||
|
Buffer.from(received, "hex"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac, hashlib
|
||||||
|
|
||||||
|
def verify_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
|
||||||
|
expected = hmac.new(
|
||||||
|
secret.encode(), raw_body + b'.' + timestamp.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
received = signature.replace('sha256=', '')
|
||||||
|
return hmac.compare_digest(expected, received)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func VerifyWebhook(secret, body, timestamp, signature string) bool {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(body + "." + timestamp))
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
received := strings.TrimPrefix(signature, "sha256=")
|
||||||
|
return hmac.Equal([]byte(expected), []byte(received))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PHP**
|
||||||
|
|
||||||
|
```php
|
||||||
|
function verifyWebhook(string $secret, string $rawBody, string $timestamp, string $signature): bool {
|
||||||
|
$expected = hash_hmac('sha256', $rawBody . '.' . $timestamp, $secret);
|
||||||
|
$received = ltrim($signature, 'sha256=');
|
||||||
|
return hash_equals($expected, $received);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retry schedule
|
||||||
|
|
||||||
|
Failed deliveries retry with exponential backoff:
|
||||||
|
|
||||||
|
| Attempt | Delay after previous |
|
||||||
|
| ------- | -------------------- |
|
||||||
|
| 1 | immediate |
|
||||||
|
| 2 | 30 seconds |
|
||||||
|
| 3 | 2 minutes |
|
||||||
|
| 4 | 10 minutes |
|
||||||
|
| 5 | 1 hour |
|
||||||
|
| 6 | 6 hours |
|
||||||
|
| 7 | 24 hours |
|
||||||
|
|
||||||
|
After 50 consecutive failures the endpoint is marked **unhealthy** and delivery pauses. Re-enable from the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Replay / testing
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/webhooks/:id/test
|
||||||
|
```
|
||||||
|
|
||||||
|
Fires a synthetic `message.received` payload to the endpoint and returns the HTTP response.
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
import { config } from 'dotenv'
|
import { config } from "dotenv";
|
||||||
import { defineConfig } from 'drizzle-kit'
|
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({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: "./drizzle",
|
||||||
schema: './src/db/schema.ts',
|
schema: "./src/db/schema.ts",
|
||||||
dialect: 'postgresql',
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
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,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
51
drizzle/0000_clean_wong.sql
Normal file
51
drizzle/0000_clean_wong.sql
Normal 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");
|
||||||
27
drizzle/0001_outgoing_joseph.sql
Normal file
27
drizzle/0001_outgoing_joseph.sql
Normal 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");
|
||||||
31
drizzle/0002_orange_clint_barton.sql
Normal file
31
drizzle/0002_orange_clint_barton.sql
Normal 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");
|
||||||
3
drizzle/0003_clammy_azazel.sql
Normal file
3
drizzle/0003_clammy_azazel.sql
Normal 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;
|
||||||
369
drizzle/meta/0000_snapshot.json
Normal file
369
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
562
drizzle/meta/0001_snapshot.json
Normal file
562
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
793
drizzle/meta/0002_snapshot.json
Normal file
793
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
847
drizzle/meta/0003_snapshot.json
Normal file
847
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
drizzle/meta/_journal.json
Normal file
34
drizzle/meta/_journal.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
import { tanstackConfig } from "@tanstack/eslint-config";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
...tanstackConfig,
|
...tanstackConfig,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'import/no-cycle': 'off',
|
"import/no-cycle": "off",
|
||||||
'import/order': 'off',
|
"import/order": "off",
|
||||||
'sort-imports': 'off',
|
"sort-imports": "off",
|
||||||
'@typescript-eslint/array-type': 'off',
|
"@typescript-eslint/array-type": "off",
|
||||||
'@typescript-eslint/require-await': 'off',
|
"@typescript-eslint/require-await": "off",
|
||||||
'pnpm/json-enforce-catalog': 'off',
|
"pnpm/json-enforce-catalog": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.js', 'prettier.config.js'],
|
ignores: ["eslint.config.js", "prettier.config.js", ".output/**"],
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
|
||||||
23
package.json
23
package.json
|
|
@ -20,39 +20,58 @@
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1047.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1047.0",
|
||||||
"@faker-js/faker": "^10.3.0",
|
"@faker-js/faker": "^10.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/match-sorter-utils": "latest",
|
"@tanstack/match-sorter-utils": "latest",
|
||||||
"@tanstack/react-devtools": "latest",
|
"@tanstack/react-devtools": "latest",
|
||||||
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-router": "latest",
|
"@tanstack/react-router": "latest",
|
||||||
"@tanstack/react-router-devtools": "latest",
|
"@tanstack/react-router-devtools": "latest",
|
||||||
"@tanstack/react-router-ssr-query": "latest",
|
"@tanstack/react-router-ssr-query": "latest",
|
||||||
"@tanstack/react-start": "latest",
|
"@tanstack/react-start": "latest",
|
||||||
"@tanstack/react-table": "latest",
|
"@tanstack/react-table": "latest",
|
||||||
"@tanstack/router-plugin": "^1.132.0",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"otpauth": "^9.5.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"rate-limiter-flexible": "^11.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.3.6"
|
"tw-animate-css": "^1.3.6",
|
||||||
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/devtools-vite": "latest",
|
"@tanstack/devtools-vite": "latest",
|
||||||
"@tanstack/eslint-config": "latest",
|
"@tanstack/eslint-config": "latest",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/busboy": "^1.5.4",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9.20.0",
|
"eslint": "^9.20.0",
|
||||||
|
|
@ -69,4 +88,4 @@
|
||||||
"lightningcss"
|
"lightningcss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
playwright.config.ts
Normal file
19
playwright.config.ts
Normal 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
21
scripts/seed-test-user.ts
Normal 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);
|
||||||
225
src/components/layout/sidebar.tsx
Normal file
225
src/components/layout/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/badge.tsx
Normal file
49
src/components/ui/badge.tsx
Normal 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 };
|
||||||
65
src/components/ui/button.tsx
Normal file
65
src/components/ui/button.tsx
Normal 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 };
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
||||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 };
|
||||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal 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
114
src/components/ui/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
216
src/db/schema.ts
216
src/db/schema.ts
|
|
@ -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', {
|
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
|
||||||
id: serial().primaryKey(),
|
|
||||||
title: text().notNull(),
|
export const users = pgTable("users", {
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
|
||||||
89
src/lib/actions/api-keys.ts
Normal file
89
src/lib/actions/api-keys.ts
Normal 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
132
src/lib/actions/apps.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/lib/actions/audit-log.ts
Normal file
44
src/lib/actions/audit-log.ts
Normal 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
90
src/lib/actions/auth.ts
Normal 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
125
src/lib/actions/messages.ts
Normal 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
196
src/lib/actions/sessions.ts
Normal 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
36
src/lib/api-key-auth.ts
Normal 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
29
src/lib/api-keys.ts
Normal 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
28
src/lib/audit.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
130
src/lib/baileys/auth-state.ts
Normal file
130
src/lib/baileys/auth-state.ts
Normal 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
12
src/lib/baileys/index.ts
Normal 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";
|
||||||
493
src/lib/baileys/session-manager.ts
Normal file
493
src/lib/baileys/session-manager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/lib/baileys/webhook.ts
Normal file
8
src/lib/baileys/webhook.ts
Normal 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
81
src/lib/crypto.ts
Normal 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
23
src/lib/dek-helper.ts
Normal 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
16
src/lib/password.ts
Normal 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
69
src/lib/query.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
92
src/lib/queue/broadcast-worker.ts
Normal file
92
src/lib/queue/broadcast-worker.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
32
src/lib/queue/cleanup-worker.ts
Normal file
32
src/lib/queue/cleanup-worker.ts
Normal 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
30
src/lib/queue/index.ts
Normal 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 };
|
||||||
72
src/lib/queue/send-worker.ts
Normal file
72
src/lib/queue/send-worker.ts
Normal 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
61
src/lib/session-api.ts
Normal 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
5
src/lib/session.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface SessionData {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
totpVerified: boolean;
|
||||||
|
}
|
||||||
24
src/lib/storage/index.ts
Normal file
24
src/lib/storage/index.ts
Normal 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
50
src/lib/storage/local.ts
Normal 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
55
src/lib/storage/s3.ts
Normal 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
35
src/lib/totp.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ClassValue } from 'clsx'
|
import type { ClassValue } from "clsx";
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from "clsx";
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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 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 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({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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 {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': 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 {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': 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 {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': 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 {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
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
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/'
|
to:
|
||||||
id: '__root__' | '/'
|
| '/'
|
||||||
|
| '/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
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
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' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
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: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -48,11 +634,355 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
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
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
return router
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof getRouter>
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,48 @@
|
||||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
import {
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
HeadContent,
|
||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
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({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{ charSet: "utf-8" },
|
||||||
charSet: 'utf-8',
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
},
|
{ title: "Wahoo — WhatsApp API Gateway" },
|
||||||
{
|
|
||||||
name: 'viewport',
|
|
||||||
content: 'width=device-width, initial-scale=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'TanStack Start Starter',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: appCss,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
links: [{ rel: "stylesheet", href: appCss }],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
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 }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -35,20 +51,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
<QueryClientProvider client={queryClient}>
|
||||||
<TanStackDevtools
|
{children}
|
||||||
config={{
|
<TanStackDevtools
|
||||||
position: 'bottom-right',
|
config={{ position: "bottom-right" }}
|
||||||
}}
|
plugins={[
|
||||||
plugins={[
|
{
|
||||||
{
|
name: "Tanstack Router",
|
||||||
name: 'Tanstack Router',
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
},
|
||||||
},
|
]}
|
||||||
]}
|
/>
|
||||||
/>
|
<Scripts />
|
||||||
<Scripts />
|
</QueryClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
src/routes/api/auth/login.ts
Normal file
84
src/routes/api/auth/login.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
src/routes/api/auth/logout.ts
Normal file
11
src/routes/api/auth/logout.ts
Normal 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
14
src/routes/api/auth/me.ts
Normal 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 } });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
47
src/routes/api/auth/totp/enroll.ts
Normal file
47
src/routes/api/auth/totp/enroll.ts
Normal 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
13
src/routes/api/health.ts
Normal 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
38
src/routes/api/ready.ts
Normal 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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
31
src/routes/api/v1/admin/apps/$id/api-keys/$keyId.ts
Normal file
31
src/routes/api/v1/admin/apps/$id/api-keys/$keyId.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
src/routes/api/v1/admin/apps/$id/api-keys/index.ts
Normal file
46
src/routes/api/v1/admin/apps/$id/api-keys/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
68
src/routes/api/v1/admin/apps/$id/index.ts
Normal file
68
src/routes/api/v1/admin/apps/$id/index.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
42
src/routes/api/v1/admin/apps/index.ts
Normal file
42
src/routes/api/v1/admin/apps/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
26
src/routes/api/v1/admin/audit-log.ts
Normal file
26
src/routes/api/v1/admin/audit-log.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal file
24
src/routes/api/v1/admin/sessions/$id/disconnect.ts
Normal 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" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
45
src/routes/api/v1/admin/sessions/$id/index.ts
Normal file
45
src/routes/api/v1/admin/sessions/$id/index.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
54
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal file
54
src/routes/api/v1/admin/sessions/$id/pair.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
31
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal file
31
src/routes/api/v1/admin/sessions/$id/qr.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
48
src/routes/api/v1/admin/sessions/index.ts
Normal file
48
src/routes/api/v1/admin/sessions/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
52
src/routes/api/v1/media/index.ts
Normal file
52
src/routes/api/v1/media/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
29
src/routes/api/v1/sessions/$id/disconnect.ts
Normal file
29
src/routes/api/v1/sessions/$id/disconnect.ts
Normal 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" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
27
src/routes/api/v1/sessions/$id/health.ts
Normal file
27
src/routes/api/v1/sessions/$id/health.ts
Normal 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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
45
src/routes/api/v1/sessions/$id/index.ts
Normal file
45
src/routes/api/v1/sessions/$id/index.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
43
src/routes/api/v1/sessions/$id/messages/$msgId/read.ts
Normal file
43
src/routes/api/v1/sessions/$id/messages/$msgId/read.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
134
src/routes/api/v1/sessions/$id/messages/audio.ts
Normal file
134
src/routes/api/v1/sessions/$id/messages/audio.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
62
src/routes/api/v1/sessions/$id/messages/broadcast.ts
Normal file
62
src/routes/api/v1/sessions/$id/messages/broadcast.ts
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
133
src/routes/api/v1/sessions/$id/messages/document.ts
Normal file
133
src/routes/api/v1/sessions/$id/messages/document.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
125
src/routes/api/v1/sessions/$id/messages/image.ts
Normal file
125
src/routes/api/v1/sessions/$id/messages/image.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
43
src/routes/api/v1/sessions/$id/messages/index.ts
Normal file
43
src/routes/api/v1/sessions/$id/messages/index.ts
Normal 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
Loading…
Add table
Reference in a new issue