feat: introduce web dashboard with multi-bot management

- add Discord OAuth2 authentication
- allow users to register their bots via token
- implement dynamic bot start/stop system
- store bots in database (multi-tenant)
- replace single-bot env setup with scalable architecture
This commit is contained in:
Puechberty Arthur
2026-04-18 01:39:12 +02:00
parent 58376568c7
commit 3063796eb0
150 changed files with 6248 additions and 1387 deletions
+7
View File
@@ -1,5 +1,12 @@
node_modules
build
dist
.next
apps/*/dist
apps/*/.next
packages/*/dist
*.tsbuildinfo
**/*.tsbuildinfo
.git
.gitignore
.env
+29 -24
View File
@@ -1,26 +1,31 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots
DATABASE_SSL=false
DATABASE_SSL_REJECT_UNAUTHORIZED=true
DATABASE_SSL_CA=
ALLOW_INSECURE_DB_SSL=false
PRESENCE_STREAM_URL=https://twitch.tv/discord
POSTGRES_DB=discord_bots
POSTGRES_USER=discord_bot
NODE_ENV=development
# Shared infrastructure
POSTGRES_DB=discord_saas
POSTGRES_USER=discord_saas
POSTGRES_PASSWORD=CHANGE_ME_STRONG_POSTGRES_PASSWORD
REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD
POSTGRES_PORT=5432
PREFIX=+
DEFAULT_LANG=en
DEV_GUILD_ID=
AUTO_DEPLOY_SLASH=false
LOG_LEVEL=info
STATE_BACKEND=memory
REDIS_URL=
COMMAND_DISPATCH_MODE=local
COMMAND_QUEUE_NAME=bot:${botId}:command-jobs
GLOBAL_RATE_LIMIT_MAX_REQUESTS=20
GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10
RATE_LIMIT_FAIL_OPEN=false
ENABLE_LEADER_ELECTION=true
REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD
DATABASE_URL=postgresql://discord_saas:CHANGE_ME_STRONG_POSTGRES_PASSWORD@localhost:5432/discord_saas
DATABASE_SSL=false
# OAuth2 + auth API
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:4000/auth/discord/callback
JWT_SECRET=CHANGE_ME_LONG_RANDOM_SECRET
JWT_EXPIRES_IN=7d
SESSION_COOKIE_NAME=saas_session
COOKIE_SECURE=false
WEB_URL=http://localhost:3000
API_BASE_URL=http://localhost:4000
# Encryption key for bot tokens (32 bytes base64)
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY
# Multi-tenant API limits
TENANT_RATE_LIMIT_MAX=120
TENANT_RATE_LIMIT_WINDOW_SECONDS=60
# Web
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000
+6
View File
@@ -1,5 +1,11 @@
node_modules/
build/
dist/
.next/
apps/*/dist/
apps/*/.next/
packages/*/dist/
*.tsbuildinfo
.env
.env.*
!.env.example
+44 -96
View File
@@ -1,116 +1,64 @@
# AGENT.md - Guide du projet template_discordjs
# AGENT.md - Guide SaaS Multi-Tenant
Version: 2.0
Version: 3.0
But
---
Ce fichier decrit la structure actuelle du projet, les conventions d'architecture et le workflow recommande pour contribuer sans introduire de dette technique.
Decrire l'architecture cible du monorepo SaaS pour bot Discord multi-tenant.
Vue d'ensemble
--------------
- Stack: TypeScript + Discord.js 14.
- Build: esbuild + typecheck TypeScript strict.
- Localisation: `src/i18n/en.json`, `src/i18n/fr.json`, `src/i18n/es.json`.
- Base de donnees: PostgreSQL, stores injectes via lifecycle centralise.
- Stack backend: Node.js + TypeScript + Express + PostgreSQL + Redis + BullMQ.
- Bot runtime: Discord.js avec gestion dynamique multi-instance.
- Frontend: Next.js App Router.
- Structure: monorepo `apps/*` + `packages/*`.
Organisation des dossiers
-------------------------
- Racine:
- `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml`.
- `src/app/`
- `bootstrap.ts`: wiring des dependances, init DB, handlers, shutdown.
- `container.ts`: contrats de services injectes.
- `src/commands/`
- Wrappers de commandes uniquement.
- Chaque fichier retourne une commande via `defineCommand(...)`.
- Aucune logique metier lourde dans cette couche.
- `src/modules/`
- `help/`: module help (service + commande).
- `presence/`: point d'entree module presence (services et contrats).
- `memberMessages/`: point d'entree module member messages (services et contrats).
- `src/features/`
- Implementation interne des modules (detail technique derriere `src/modules/*`).
- `src/core/`
- `commands/`: parser, registry, slash builder, usage.
- `execution/`: pipeline d'execution des commandes (dispatch local/worker, cooldown/rate-limit stores).
- `runtime/`: coordination multi-instance (leader election / startup locks).
- `logging/`: logger structure JSON (`pino`).
- `discord/`: helpers partages (resolveReplyMessage, session registry).
- `src/database/`
- `stores/`: implementations PostgreSQL concretes.
- `migrations/`: fichiers SQL versionnes appliques via `npm run migrate`.
- `dbLifecycle.ts`: init/shutdown centralise.
- `src/validators/`
- Validation et sanitation metier (presence, member messages).
- `src/types/`
- Types purs partages (pas de logique metier).
- `src/events/`
- Enregistrement des listeners Discord, relies aux services injectes.
- `src/handlers/`
- Entrees prefix/slash et adaptation du contexte d'execution.
- `src/utils/`
- Utilitaires generiques transverses.
- `apps/api/`
- OAuth2 Discord, JWT cookie, routes multi-tenant, validation token bot, publication jobs BullMQ.
- `apps/bot/`
- BotManager dynamique (`Map<botId, Client>`), worker de controle (`start/stop/restart`).
- `apps/web/`
- Dashboard utilisateur (login, liste bots, ajout bot, actions runtime).
- `packages/shared/`
- Types partages, constantes queue, helpers Redis namespacing, chiffrement token AES-GCM.
- `database/migrations/`
- SQL versionne (`schema_migrations`) avec schema multi-tenant.
Principes d'architecture
------------------------
- Commands UI only:
- Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`.
- Modules own business logic:
- Toute logique metier testable va dans `src/modules/*` (les wrappers `src/commands/*` restent minces).
- Core is shared infra:
- Helpers communs Discord, execution pipeline, parser, registry.
- Execution pipeline is decoupled:
- Parsing reste dans `src/handlers/*`.
- Dispatch est route par `src/core/execution/dispatch.ts` (`local` ou `worker`).
- Execution metier passe par `CommandExecutor` avec stores abstraits (memory/Redis).
- Contexts are split for low coupling:
- `ExecutionContext` (core), `TransportContext` (Discord), `I18nContext` (localization).
- `CommandExecutionContext` conserve des alias legacy pour ne pas casser les commandes existantes.
- Validators isolate rules:
- Les regles de validation/sanitation ne vont pas dans `src/types`.
- Database via repository contracts:
- Les modules dependent d'interfaces, pas de singletons globaux.
- Bootstrap owns lifecycle:
- Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`.
- Multi-tenant strict:
- toutes les requetes de lecture/ecriture passent par `tenant_id`.
- tables critiques liees a `tenant_id` et/ou `owner_user_id`.
- Pas de secret en clair:
- token bot chiffre en DB (`token_ciphertext`, `token_iv`, `token_tag`).
- Controle runtime decouple:
- API publie des jobs, bot manager consomme et execute.
- Separation responsabilites:
- API: auth + orchestration.
- Bot: runtime Discord.
- Web: UX dashboard.
Conventions de code
-------------------
- TypeScript strict obligatoire.
- Exports nommes preferes.
- Fichiers en lowerCamelCase.
- Commentaires courts uniquement pour clarifier un bloc non trivial.
- Eviter tout couplage direct d'une feature vers une autre sans passer par contrats explicites.
- TypeScript strict.
- Exports nommes.
- Erreurs API explicites et codes HTTP coherents.
- Logs sans fuite de secrets.
Procedure: ajouter une commande
--------------------------------
1. Creer un wrapper dans `src/commands/`.
2. Declarer la commande avec `defineCommand({ ... })`.
3. Deleguer `execute` vers un module `src/modules/...`.
4. Ajouter la commande dans `createCommandList` (`src/commands/index.ts`).
5. Ajouter les cles i18n dans `src/i18n/*.json`.
6. Ajouter les tests cibles (`tests/`) selon la logique introduite.
Procedure: ajouter une feature
------------------------------
1. Creer `src/modules/<feature>/`.
2. Definir les contrats repository dans la feature.
3. Implementer le service metier independant de Discord quand possible.
4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`.
5. Ajouter une migration SQL versionnee dans `database/migrations/`.
6. Cablage d'injection dans `src/app/bootstrap.ts`.
7. Ajouter tests unitaires et, si necessaire, integration.
Tests et verification
---------------------
- Verification minimale avant PR:
- `npm run migrate`
- `npm run typecheck`
- `npm test`
- En cas de refactor de structure:
- verifier README + AGENT.md + i18n + imports.
Workflow recommande
-------------------
1. Ajouter/adapter migration SQL dans `database/migrations`.
2. Adapter repositories API/Bot avec filtre tenant.
3. Ajouter endpoint API + validation zod.
4. Brancher action queue si runtime impacte.
5. Mettre a jour le dashboard web.
6. Valider `npm run typecheck` puis `docker compose up -d --build`.
Securite
--------
- Ne jamais committer de secrets.
- `.env*` (sauf `.env.example`) doit rester ignore.
- Eviter d'exposer des credentials dans logs/scripts de debug.
- Ne jamais committer `.env`.
- Garder `TOKEN_ENCRYPTION_KEY` hors depot.
- Appliquer `httpOnly` + `sameSite` sur cookie session.
- Eviter les logs de payloads sensibles (token, secrets).
-33
View File
@@ -1,33 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY scripts ./scripts
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache fontconfig ttf-dejavu
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/build ./build
COPY scripts ./scripts
COPY database ./database
RUN addgroup -S app && adduser -S app -G app
RUN chown -R app:app /app
USER app
CMD ["sh", "-c", "node scripts/migrate.mjs && node build/index.js"]
+155 -106
View File
@@ -1,124 +1,173 @@
# Discord.js v14 Framework Template
# Discord Bot SaaS Platform (Multi-Tenant)
Professional command framework template for Discord.js `14.26.2` with:
Plateforme SaaS multi-tenant pour gérer des bots Discord avec un seul service bot dynamique.
- Single command object schema
- Minimal command authoring (`name`, `category`, `execute`)
- Optional per-user command cooldown (`cooldown` in seconds)
- Shared execution logic for prefix and slash
- Dispatch pipeline ready for local execution or worker queue mode
- Global user rate limiting (memory or Redis backend)
- Structured JSON logging with `pino`
- External JSON i18n
- Automatic prefix and slash localizations from locale files
- One localized `name` per language drives both prefix and slash triggers
- Typed argument schema
- User permission checks
- Auto-generated help from command metadata
## Vue d'ensemble
## Presence Storage
Cette version transforme le modèle "1 conteneur = 1 bot" en architecture scalable:
- The `presence` command is persisted in PostgreSQL.
- Storage is keyed by `bot_id` (Discord user id), so one PostgreSQL instance can serve multiple bots.
- Presence survives bot restarts and container restarts.
- Un seul `apps/bot` qui gère `N` bots dynamiquement
- `apps/api` pour OAuth2 Discord + API sécurisée + orchestration des bots
- `apps/web` (Next.js) pour le dashboard
- PostgreSQL pour les données tenant-scopées
- Redis + BullMQ pour la file d'actions de contrôle (`start`, `stop`, `restart`)
## Setup
## Structure Monorepo
1. Install dependencies:
npm install
2. Create environment file:
```text
/apps
/web # Next.js dashboard
/api # Backend API (OAuth2, JWT, gestion bots)
/bot # Bot manager multi-instance dynamique
/packages
/shared # Types, crypto token, helpers Redis namespacés
/database
/migrations # SQL versionné (inclut multi-tenant SaaS)
docker-compose.yml
.env
```
## Architecture Runtime
### 1) API (`apps/api`)
- OAuth2 Discord (`/auth/discord/login`, `/auth/discord/callback`)
- Session JWT en cookie `httpOnly`
- Endpoints multi-tenant pour bots (`/api/bots`)
- Validation du token bot via l'API Discord avant stockage
- Chiffrement AES-256-GCM des tokens en base
- Publication des actions de contrôle via BullMQ (Redis)
- Rate limit par tenant (clé Redis namespacée `tenant:{tenantId}:...`)
### 2) Bot Manager (`apps/bot`)
- Charge les bots à relancer depuis PostgreSQL au démarrage
- Maintient une map en mémoire:
```ts
Map<botId, DiscordClient>
```
- Worker BullMQ: consomme les jobs `start|stop|restart`
- Met à jour l'état runtime (`starting`, `running`, `stopping`, `error`)
- Journalise les événements runtime en base (`bot_runtime_events`)
### 3) Web (`apps/web`)
- Pages principales:
- `/login`
- `/dashboard`
- Dashboard utilisateur:
- Ajouter un bot (token)
- Voir la liste des bots du tenant
- `start / stop / restart`
## Schéma PostgreSQL (core SaaS)
Migration: `database/migrations/0004_saas_multitenant.sql`
- `tenants`
- `id` (UUID)
- `owner_user_id`
- `users`
- `tenant_id` FK
- `discord_user_id` (unique)
- `role`
- `bots`
- `tenant_id` FK
- `owner_user_id` FK
- `discord_bot_id` (unique global)
- `token_ciphertext`, `token_iv`, `token_tag`
- `status`, `last_error`
- `bot_runtime_events`
- logs runtime par bot + tenant
Les tables legacy de configuration (`bot_presence_states`, `bot_member_message_configs`, `bot_log_event_configs`) sont enrichies avec `tenant_id` et `owner_user_id` pour respecter l'isolation multi-tenant stricte.
## API Principale
### Auth
- `GET /auth/discord/login`
- `GET /auth/discord/callback`
- `POST /auth/logout`
- `GET /api/me`
### Bots
- `GET /api/bots`
- `POST /api/bots`
- body: `{ token: string, displayName?: string }`
- `POST /api/bots/:botId/start`
- `POST /api/bots/:botId/stop`
- `POST /api/bots/:botId/restart`
Tous ces endpoints sont tenant-scopés via la session.
## Sécurité
- Tokens bot jamais stockés en clair
- AES-256-GCM avec `TOKEN_ENCRYPTION_KEY` (32 bytes en base64)
- Validation token côté Discord avant insertion
- Session auth via JWT httpOnly cookie
- Filtrage systématique des requêtes par `tenant_id`
- Clés Redis namespacées par tenant
- Rate limiting par tenant sur les actions de contrôle
## Docker Compose (fixe)
Services:
- `web`
- `api`
- `bot`
- `postgres`
- `redis`
Pas de service par bot. Pas de `.env` par bot.
## Démarrage local
1. Copier l'environnement:
```bash
cp .env.example .env
3. Fill required values in `.env`:
- `DISCORD_TOKEN`
- `DISCORD_CLIENT_ID`
- `DATABASE_URL`
4. Optional values:
- `DATABASE_SSL` (`true` for managed cloud DB, `false` for local Docker)
- `DATABASE_SSL_REJECT_UNAUTHORIZED` (`true` by default for secure TLS verification)
- `DATABASE_SSL_CA` (optional CA cert chain, supports escaped newlines)
- `ALLOW_INSECURE_DB_SSL` (`false` by default; set `true` only for local/dev exceptions)
- `PRESENCE_STREAM_URL` (used when activity type is `STREAMING`)
- `AUTO_DEPLOY_SLASH` (`true` to sync slash commands on startup)
- `DEV_GUILD_ID` (optional guild scope for faster slash sync)
- `LOG_LEVEL` (default `info`, JSON logs)
- `STATE_BACKEND` (`memory` or `redis`)
- `REDIS_URL` (required when `STATE_BACKEND=redis`)
- `GLOBAL_RATE_LIMIT_MAX_REQUESTS` (global per-user request budget)
- `GLOBAL_RATE_LIMIT_WINDOW_SECONDS` (window size in seconds)
- `COMMAND_DISPATCH_MODE` (`local` or `worker`; enable `worker` only if a queue consumer is deployed)
- `COMMAND_QUEUE_NAME` (Redis list used in `worker` mode)
- `ENABLE_LEADER_ELECTION` (`true` to guard startup jobs in multi-instance)
5. Run migrations:
npm run migrate
6. Start in development:
npm run dev
7. Validate code quality:
- npm run typecheck
- npm run test
- npm run check
```
## Docker Deployment (2 Bots + PostgreSQL)
2. Générer une clé de chiffrement valide (32 bytes base64):
1. Fill production env files:
- `.env.bot-alpha.prod`
- `.env.bot-beta.prod`
2. Set strong shared DB credentials:
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
3. Start stack:
```bash
openssl rand -base64 32
```
3. Installer les dépendances monorepo:
```bash
npm install
```
4. Lancer la stack complète:
```bash
docker compose up -d --build
4. Stop stack:
docker compose down
```
By default, `docker-compose.yml` provisions:
- `bot_alpha`: Discord bot instance A
- `bot_beta`: Discord bot instance B
- `postgres`: PostgreSQL 16 with persistent volume `postgres_data`
5. Dashboard:
Both bots use the same PostgreSQL service, and data remains isolated per bot through `bot_id` keys.
- `http://localhost:3000`
Security defaults:
- PostgreSQL is exposed only on the internal Compose network (`expose: 5432`, no host bind).
- Bot containers enforce `no-new-privileges` and graceful stop.
- Log rotation is enabled (`max-size=10m`, `max-file=3`).
- Database TLS verification remains strict by default when SSL is enabled.
API:
## Architecture
- `http://localhost:4000/health`
- `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown
- `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute)
- `src/modules/help/*`: help command module (service + command contract)
- `src/modules/presence/*`: module entrypoint for presence runtime and contracts
- `src/modules/memberMessages/*`: module entrypoint for member-message runtime and contracts
- `src/features/*`: implementation details used behind `src/modules/*`
- `src/core/commands/*`: registry, parsing, slash payload, usage helpers
- `src/core/execution/CommandExecutor.ts`: execution core (permissions, cooldown, global rate limit)
- `src/core/execution/dispatch.ts`: dispatch abstraction (`local` or `worker`)
- `src/core/execution/cooldownStore.ts`: cooldown store contracts and memory/Redis implementations
- `src/core/execution/globalRateLimitStore.ts`: global limiter contracts and memory/Redis implementations
- `src/core/runtime/leaderCoordinator.ts`: leader-only startup coordination for multi-instance deployments
- `src/core/logging/logger.ts`: centralized structured logger (`pino`)
- `src/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry)
- `src/database/stores/*`: PostgreSQL store implementations
- `src/database/dbLifecycle.ts`: centralized DB init/shutdown lifecycle
- `src/validators/*`: business validation/sanitization
- `src/types/*`: pure shared types and contracts
- `src/i18n/*.json`: external i18n dictionaries
Bot manager health:
## Included Commands
- `http://localhost:4100/health`
- `kiss` (`fun`) with required `user` arg
- `ping` (`utility`)
- `welcome` (`utility`) interactive welcome-message panel
- `goodbye` (`utility`) interactive goodbye-message panel
- `presence` (`utility`) with interactive status/activity/text panel
- `help` (`core`) with auto category and usage generation
## Notes Scalabilité
## Adding A Command
1. Create a command object in `src/commands/...`
2. Follow the schema in `src/types/command.ts`
3. Add command to `src/commands/index.ts`
4. Put business logic in `src/modules/<module>/...` and keep `src/commands/*` as wrappers only
5. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically
- Une instance bot unique peut gérer des dizaines/centaines de bots selon ressources.
- Pour monter en charge horizontalement:
- scaler `apps/api`
- scaler `apps/bot` avec coordination queue/locks
- conserver Redis + PostgreSQL managés
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/api/package.json ./apps/api/package.json
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /workspace
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY apps/api ./apps/api
COPY database ./database
RUN npm run build -w @saas/shared && npm exec -- tsc -p apps/api/tsconfig.json
FROM node:20-alpine AS runner
WORKDIR /workspace
ENV NODE_ENV=production
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/api/package.json ./apps/api/package.json
COPY apps/api/scripts ./apps/api/scripts
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
COPY --from=builder /workspace/apps/api/dist ./apps/api/dist
COPY database ./database
CMD ["sh", "-c", "npm run migrate -w @saas/api && npm run start -w @saas/api"]
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@saas/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json",
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js",
"migrate": "node scripts/migrate.mjs"
},
"dependencies": {
"@saas/shared": "*",
"bullmq": "^5.21.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"pg": "^8.13.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/pg": "^8.11.11"
}
}
@@ -6,8 +6,8 @@ import { fileURLToPath } from "node:url";
import { config as loadEnv } from "dotenv";
import { Pool } from "pg";
const LOCK_CLASS_ID = 9421;
const LOCK_OBJECT_ID = 2601;
const LOCK_CLASS_ID = 7813;
const LOCK_OBJECT_ID = 4312;
const parseBoolean = (value, fallback = false) => {
if (value === undefined || value === null || String(value).trim().length === 0) {
@@ -18,15 +18,6 @@ const parseBoolean = (value, fallback = false) => {
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const optionalString = (value) => {
if (value === undefined || value === null) {
return undefined;
}
const trimmed = String(value).trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
const listMigrationFiles = async (migrationsDir) => {
@@ -40,11 +31,13 @@ const listMigrationFiles = async (migrationsDir) => {
const loadMigrations = async (migrationsDir) => {
const files = await listMigrationFiles(migrationsDir);
if (files.length === 0) {
throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`);
}
const migrations = [];
for (const fileName of files) {
const filePath = path.join(migrationsDir, fileName);
const sql = await readFile(filePath, "utf8");
@@ -70,38 +63,19 @@ const ensureMigrationTable = async (client) => {
};
const applyMigrations = async (client, migrations) => {
const appliedResult = await client.query(
"SELECT version, checksum FROM schema_migrations ORDER BY version ASC",
);
const applied = await client.query("SELECT version, checksum FROM schema_migrations ORDER BY version ASC");
const appliedByVersion = new Map(
appliedResult.rows.map((row) => [String(row.version), String(row.checksum)]),
);
const migrationFileNames = new Set(migrations.map((entry) => entry.fileName));
for (const version of appliedByVersion.keys()) {
if (!migrationFileNames.has(version)) {
throw new Error(
`[migrate] migration history drift detected: database has version \"${version}\" but file is missing locally.`,
);
}
}
let appliedCount = 0;
let skippedCount = 0;
const appliedByVersion = new Map(applied.rows.map((row) => [String(row.version), String(row.checksum)]));
for (const migration of migrations) {
const existingChecksum = appliedByVersion.get(migration.fileName);
if (existingChecksum) {
if (existingChecksum !== migration.checksum) {
throw new Error(
`[migrate] checksum mismatch for ${migration.fileName}. Expected ${existingChecksum}, got ${migration.checksum}.`,
`[migrate] checksum mismatch for ${migration.fileName}. expected ${existingChecksum}, got ${migration.checksum}`,
);
}
skippedCount += 1;
continue;
}
@@ -114,49 +88,30 @@ const applyMigrations = async (client, migrations) => {
[migration.fileName, migration.checksum],
);
await client.query("COMMIT");
appliedCount += 1;
console.log(`[migrate] applied ${migration.fileName}`);
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error });
}
}
console.log(`[migrate] completed. applied=${appliedCount}, skipped=${skippedCount}`);
};
const main = async () => {
loadEnv();
const databaseUrl = optionalString(process.env.DATABASE_URL);
if (!databaseUrl) {
if (!process.env.DATABASE_URL) {
throw new Error("[migrate] DATABASE_URL is required");
}
const databaseSsl = parseBoolean(process.env.DATABASE_SSL, false);
const rejectUnauthorized = parseBoolean(process.env.DATABASE_SSL_REJECT_UNAUTHORIZED, true);
const allowInsecureDbSsl = parseBoolean(process.env.ALLOW_INSECURE_DB_SSL, false);
if (databaseSsl && !rejectUnauthorized && !allowInsecureDbSsl) {
throw new Error(
"[migrate] insecure TLS configuration blocked. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or ALLOW_INSECURE_DB_SSL=true for non-production use.",
);
}
const databaseSslCa = optionalString(process.env.DATABASE_SSL_CA)?.replace(/\\n/g, "\n");
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(scriptDir, "../../..");
const migrationsDir = path.join(rootDir, "database", "migrations");
const migrations = await loadMigrations(migrationsDir);
const pool = new Pool({
connectionString: databaseUrl,
ssl: databaseSsl
connectionString: process.env.DATABASE_URL,
ssl: parseBoolean(process.env.DATABASE_SSL, false)
? {
rejectUnauthorized,
...(databaseSslCa ? { ca: databaseSslCa } : {}),
rejectUnauthorized: true,
}
: undefined,
});
@@ -164,9 +119,12 @@ const main = async () => {
const client = await pool.connect();
try {
const migrations = await loadMigrations(migrationsDir);
await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]);
await ensureMigrationTable(client);
await applyMigrations(client, migrations);
console.log("[migrate] completed");
} finally {
await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined);
client.release();
+122
View File
@@ -0,0 +1,122 @@
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { env } from "../config/env.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const oauthTokenSchema = z.object({
access_token: z.string(),
token_type: z.string(),
});
const discordUserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
global_name: z.string().nullable().optional(),
});
const discordBotSchema = z.object({
id: z.string(),
username: z.string(),
discriminator: z.string(),
global_name: z.string().nullable().optional(),
});
export interface DiscordIdentity {
id: string;
username: string;
avatarUrl: string | null;
}
export interface DiscordBotIdentity {
id: string;
displayName: string;
}
export const createOauthState = (): string => {
return randomBytes(24).toString("base64url");
};
export const buildDiscordLoginUrl = (state: string): string => {
const params = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
redirect_uri: env.DISCORD_REDIRECT_URI,
response_type: "code",
scope: "identify",
state,
prompt: "consent",
});
return `https://discord.com/oauth2/authorize?${params.toString()}`;
};
const createAvatarUrl = (discordUserId: string, avatarHash: string | null): string | null => {
if (!avatarHash) {
return null;
}
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
};
export const exchangeCodeForDiscordIdentity = async (code: string): Promise<DiscordIdentity> => {
const payload = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: env.DISCORD_REDIRECT_URI,
});
const tokenResponse = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: payload,
});
if (!tokenResponse.ok) {
throw new Error("Discord OAuth token exchange failed");
}
const tokenJson = oauthTokenSchema.parse(await tokenResponse.json());
const userResponse = await fetch(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `${tokenJson.token_type} ${tokenJson.access_token}`,
},
});
if (!userResponse.ok) {
throw new Error("Discord user profile fetch failed");
}
const userJson = discordUserSchema.parse(await userResponse.json());
return {
id: userJson.id,
username: userJson.global_name ?? userJson.username,
avatarUrl: createAvatarUrl(userJson.id, userJson.avatar),
};
};
export const validateDiscordBotToken = async (botToken: string): Promise<DiscordBotIdentity> => {
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `Bot ${botToken}`,
},
});
if (!response.ok) {
throw new Error("Invalid Discord bot token");
}
const botJson = discordBotSchema.parse(await response.json());
return {
id: botJson.id,
displayName: botJson.global_name ?? botJson.username,
};
};
+43
View File
@@ -0,0 +1,43 @@
import { SignJWT, jwtVerify } from "jose";
import { env } from "../config/env.js";
import type { AuthSession } from "../types/auth.js";
interface SessionJwtPayload {
sub: string;
tenantId: string;
discordUserId: string;
username: string;
}
const secret = new TextEncoder().encode(env.JWT_SECRET);
export const issueSessionToken = async (session: AuthSession): Promise<string> => {
return new SignJWT({
tenantId: session.tenantId,
discordUserId: session.discordUserId,
username: session.username,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(session.userId)
.setIssuedAt()
.setExpirationTime(env.JWT_EXPIRES_IN)
.sign(secret);
};
export const verifySessionToken = async (token: string): Promise<AuthSession> => {
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
algorithms: ["HS256"],
});
if (!verified.payload.sub) {
throw new Error("session token missing subject");
}
return {
userId: verified.payload.sub,
tenantId: verified.payload.tenantId,
discordUserId: verified.payload.discordUserId,
username: verified.payload.username,
};
};
+31
View File
@@ -0,0 +1,31 @@
import { config as loadEnv } from "dotenv";
import { z } from "zod";
loadEnv();
const parseBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4000),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET is required"),
DISCORD_REDIRECT_URI: z.string().url("DISCORD_REDIRECT_URI must be a valid URL"),
WEB_URL: z.string().url("WEB_URL must be a valid URL"),
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_EXPIRES_IN: z.string().min(2).default("7d"),
SESSION_COOKIE_NAME: z.string().min(1).default("saas_session"),
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
TENANT_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
});
export const env = envSchema.parse(process.env);
+14
View File
@@ -0,0 +1,14 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
export const createPgPool = (): Pool => {
return new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL
? {
rejectUnauthorized: true,
}
: undefined,
});
};
+328
View File
@@ -0,0 +1,328 @@
import type { EncryptedToken, PublicBot } from "@saas/shared";
import type { Pool } from "pg";
export interface DbUser {
id: string;
tenantId: string;
discordUserId: string;
username: string;
avatarUrl: string | null;
role: "owner" | "member";
}
interface CreateOrUpdateBotInput {
tenantId: string;
ownerUserId: string;
discordBotId: string;
displayName: string;
encryptedToken: EncryptedToken;
}
const mapUser = (row: Record<string, unknown>): DbUser => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
discordUserId: String(row.discord_user_id),
username: String(row.username),
avatarUrl: row.avatar_url ? String(row.avatar_url) : null,
role: (row.role as "owner" | "member") ?? "member",
};
};
const mapPublicBot = (row: Record<string, unknown>): PublicBot => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
discordBotId: String(row.discord_bot_id),
displayName: String(row.display_name),
status: row.status as PublicBot["status"],
lastError: row.last_error ? String(row.last_error) : null,
createdAt: new Date(String(row.created_at)).toISOString(),
updatedAt: new Date(String(row.updated_at)).toISOString(),
};
};
export const upsertUserFromDiscord = async (
pool: Pool,
identity: {
discordUserId: string;
username: string;
avatarUrl: string | null;
},
): Promise<DbUser> => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const existing = await client.query(
`
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
FROM users
WHERE discord_user_id = $1
FOR UPDATE
`,
[identity.discordUserId],
);
if (existing.rowCount && existing.rows[0]) {
const updated = await client.query(
`
UPDATE users
SET username = $2,
avatar_url = $3,
updated_at = NOW()
WHERE id = $1
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
`,
[existing.rows[0].id, identity.username, identity.avatarUrl],
);
await client.query("COMMIT");
return mapUser(updated.rows[0] as Record<string, unknown>);
}
const tenantInsert = await client.query<{ id: string }>(
`
INSERT INTO tenants DEFAULT VALUES
RETURNING id
`,
);
const tenantId = tenantInsert.rows[0]?.id;
if (!tenantId) {
throw new Error("Failed to create tenant");
}
const userInsert = await client.query(
`
INSERT INTO users (
tenant_id,
discord_user_id,
username,
avatar_url,
role
) VALUES ($1, $2, $3, $4, 'owner')
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
`,
[tenantId, identity.discordUserId, identity.username, identity.avatarUrl],
);
const user = mapUser(userInsert.rows[0] as Record<string, unknown>);
await client.query(
`
UPDATE tenants
SET owner_user_id = $1
WHERE id = $2
`,
[user.id, tenantId],
);
await client.query("COMMIT");
return user;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
};
export const getUserByIdAndTenant = async (
pool: Pool,
userId: string,
tenantId: string,
): Promise<DbUser | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
FROM users
WHERE id = $1 AND tenant_id = $2
LIMIT 1
`,
[userId, tenantId],
);
if (!result.rows[0]) {
return null;
}
return mapUser(result.rows[0] as Record<string, unknown>);
};
export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise<PublicBot[]> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
FROM bots
WHERE tenant_id = $1
ORDER BY created_at DESC
`,
[tenantId],
);
return result.rows.map((row) => mapPublicBot(row as Record<string, unknown>));
};
export const getBotForTenant = async (
pool: Pool,
tenantId: string,
botId: string,
): Promise<PublicBot | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
FROM bots
WHERE tenant_id = $1 AND id = $2
LIMIT 1
`,
[tenantId, botId],
);
if (!result.rows[0]) {
return null;
}
return mapPublicBot(result.rows[0] as Record<string, unknown>);
};
export const createOrUpdateBotForTenant = async (
pool: Pool,
input: CreateOrUpdateBotInput,
): Promise<PublicBot> => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const existingByDiscordBot = await client.query(
`
SELECT id, tenant_id
FROM bots
WHERE discord_bot_id = $1
FOR UPDATE
`,
[input.discordBotId],
);
if (existingByDiscordBot.rows[0] && existingByDiscordBot.rows[0].tenant_id !== input.tenantId) {
throw new Error("BOT_ALREADY_CLAIMED");
}
const existingInTenant = await client.query(
`
SELECT id
FROM bots
WHERE tenant_id = $1 AND discord_bot_id = $2
LIMIT 1
`,
[input.tenantId, input.discordBotId],
);
let upserted;
if (existingInTenant.rows[0]) {
upserted = await client.query(
`
UPDATE bots
SET owner_user_id = $2,
display_name = $3,
token_ciphertext = $4,
token_iv = $5,
token_tag = $6,
status = 'stopped',
last_error = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
`,
[
existingInTenant.rows[0].id,
input.ownerUserId,
input.displayName,
input.encryptedToken.ciphertext,
input.encryptedToken.iv,
input.encryptedToken.tag,
],
);
} else {
upserted = await client.query(
`
INSERT INTO bots (
tenant_id,
owner_user_id,
discord_bot_id,
display_name,
token_ciphertext,
token_iv,
token_tag,
status
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'stopped')
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
`,
[
input.tenantId,
input.ownerUserId,
input.discordBotId,
input.displayName,
input.encryptedToken.ciphertext,
input.encryptedToken.iv,
input.encryptedToken.tag,
],
);
}
await client.query("COMMIT");
return mapPublicBot(upserted.rows[0] as Record<string, unknown>);
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
};
export const setBotStatusForTenant = async (
pool: Pool,
tenantId: string,
botId: string,
status: PublicBot["status"],
lastError: string | null = null,
): Promise<void> => {
await pool.query(
`
UPDATE bots
SET status = $3,
last_error = $4,
updated_at = NOW()
WHERE tenant_id = $1 AND id = $2
`,
[tenantId, botId, status, lastError],
);
};
export const insertBotRuntimeEvent = async (
pool: Pool,
input: {
tenantId: string;
botId: string;
level: "info" | "warn" | "error";
message: string;
metadata?: unknown;
},
): Promise<void> => {
await pool.query(
`
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[
input.tenantId,
input.botId,
input.level,
input.message,
JSON.stringify(input.metadata ?? {}),
],
);
};
+125
View File
@@ -0,0 +1,125 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import { Redis } from "ioredis";
import { parseTokenEncryptionKey } from "@saas/shared";
import { env } from "./config/env.js";
import { createPgPool } from "./db/pool.js";
import { getUserByIdAndTenant } from "./db/repositories.js";
import { requireAuth } from "./middleware/auth.js";
import { createTenantRateLimit } from "./middleware/tenantRateLimit.js";
import { createAuthRouter } from "./routes/authRoutes.js";
import { createBotRouter } from "./routes/botRoutes.js";
import { createBotControlQueue } from "./services/botControlQueue.js";
const bootstrap = async (): Promise<void> => {
const pgPool = createPgPool();
const redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
});
const tokenEncryptionKey = parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY);
const botControlQueue = createBotControlQueue(redis.duplicate());
await pgPool.query("SELECT 1");
await redis.ping();
const app = express();
app.disable("x-powered-by");
app.use(
cors({
origin: env.WEB_URL,
credentials: true,
}),
);
app.use(express.json({ limit: "200kb" }));
app.use(cookieParser());
app.get("/health", (_req, res) => {
res.status(200).json({
ok: true,
service: "api",
timestamp: new Date().toISOString(),
});
});
app.use("/auth", createAuthRouter({ pool: pgPool }));
app.get("/api/me", requireAuth, async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const user = await getUserByIdAndTenant(pgPool, req.auth.userId, req.auth.tenantId);
if (!user) {
res.status(401).json({ error: "Session does not match an existing user" });
return;
}
res.json({
user: {
id: user.id,
tenantId: user.tenantId,
discordUserId: user.discordUserId,
username: user.username,
avatarUrl: user.avatarUrl,
role: user.role,
},
});
});
const tenantControlRateLimit = createTenantRateLimit({
redis,
scope: "bot-control",
maxRequests: env.TENANT_RATE_LIMIT_MAX,
windowSeconds: env.TENANT_RATE_LIMIT_WINDOW_SECONDS,
});
app.use(
"/api/bots",
requireAuth,
createBotRouter({
pool: pgPool,
queue: botControlQueue,
tokenEncryptionKey,
tenantControlRateLimit,
}),
);
const server = app.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`[api] listening on :${env.PORT}`);
});
const shutdown = async (signal: string): Promise<void> => {
// eslint-disable-next-line no-console
console.log(`[api] shutdown requested (${signal})`);
server.close(async () => {
await botControlQueue.close();
await redis.quit().catch(() => undefined);
await pgPool.end().catch(() => undefined);
process.exit(0);
});
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error("[api] fatal startup error", error);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
import type { RequestHandler } from "express";
import { env } from "../config/env.js";
import { verifySessionToken } from "../auth/jwt.js";
export const requireAuth: RequestHandler = async (req, res, next) => {
const sessionToken = req.cookies?.[env.SESSION_COOKIE_NAME];
if (!sessionToken) {
res.status(401).json({ error: "Authentication required" });
return;
}
try {
req.auth = await verifySessionToken(sessionToken);
next();
} catch {
res.status(401).json({ error: "Invalid or expired session" });
}
};
@@ -0,0 +1,47 @@
import type { RequestHandler } from "express";
import type { Redis } from "ioredis";
import { tenantRateLimitKey } from "@saas/shared";
interface TenantRateLimitOptions {
redis: Redis;
scope: string;
maxRequests: number;
windowSeconds: number;
}
export const createTenantRateLimit = ({
redis,
scope,
maxRequests,
windowSeconds,
}: TenantRateLimitOptions): RequestHandler => {
return async (req, res, next) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const key = tenantRateLimitKey(req.auth.tenantId, scope);
try {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
if (count > maxRequests) {
res.status(429).json({
error: "Rate limit reached",
limit: maxRequests,
windowSeconds,
});
return;
}
next();
} catch {
res.status(503).json({ error: "Rate limit backend unavailable" });
}
};
};
+84
View File
@@ -0,0 +1,84 @@
import type { Router } from "express";
import { Router as expressRouter } from "express";
import type { Pool } from "pg";
import { buildDiscordLoginUrl, createOauthState, exchangeCodeForDiscordIdentity } from "../auth/discordOAuth.js";
import { issueSessionToken } from "../auth/jwt.js";
import { env } from "../config/env.js";
import { upsertUserFromDiscord } from "../db/repositories.js";
const OAUTH_STATE_COOKIE_NAME = "discord_oauth_state";
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
interface AuthRouterDependencies {
pool: Pool;
}
const sessionCookieConfig = {
httpOnly: true,
sameSite: "lax" as const,
secure: env.COOKIE_SECURE,
maxAge: SESSION_TTL_MS,
path: "/",
};
export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
const router = expressRouter();
router.get("/discord/login", (_req, res) => {
const state = createOauthState();
res.cookie(OAUTH_STATE_COOKIE_NAME, state, {
httpOnly: true,
sameSite: "lax",
secure: env.COOKIE_SECURE,
maxAge: OAUTH_STATE_TTL_MS,
path: "/auth/discord/callback",
});
res.redirect(buildDiscordLoginUrl(state));
});
router.get("/discord/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
if (!code || !state || !cookieState || state !== cookieState) {
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
return;
}
try {
const discordIdentity = await exchangeCodeForDiscordIdentity(code);
const user = await upsertUserFromDiscord(pool, {
discordUserId: discordIdentity.id,
username: discordIdentity.username,
avatarUrl: discordIdentity.avatarUrl,
});
const sessionToken = await issueSessionToken({
userId: user.id,
tenantId: user.tenantId,
discordUserId: user.discordUserId,
username: user.username,
});
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
res.redirect(`${env.WEB_URL}/dashboard`);
} catch {
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
}
});
router.post("/logout", (_req, res) => {
res.clearCookie(env.SESSION_COOKIE_NAME, { path: "/" });
res.status(204).send();
});
return router;
};
+156
View File
@@ -0,0 +1,156 @@
import type { RequestHandler, Router } from "express";
import { Router as expressRouter } from "express";
import type { Queue } from "bullmq";
import { z } from "zod";
import type { Pool } from "pg";
import type { BotControlJob } from "@saas/shared";
import { encryptToken } from "@saas/shared";
import { validateDiscordBotToken } from "../auth/discordOAuth.js";
import {
createOrUpdateBotForTenant,
getBotForTenant,
insertBotRuntimeEvent,
listBotsForTenant,
setBotStatusForTenant,
} from "../db/repositories.js";
import { enqueueBotControlAction } from "../services/botControlQueue.js";
const addBotSchema = z.object({
token: z.string().min(20, "Bot token is required"),
displayName: z.string().min(2).max(80).optional(),
});
const botIdParamsSchema = z.object({
botId: z.string().uuid("botId must be a valid UUID"),
});
interface BotRouterDependencies {
pool: Pool;
queue: Queue<BotControlJob>;
tokenEncryptionKey: Buffer;
tenantControlRateLimit: RequestHandler;
}
const queueBotAction = (
action: "start" | "stop" | "restart",
statusDuringAction: "starting" | "stopping",
deps: BotRouterDependencies,
): RequestHandler => {
return async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const parsedParams = botIdParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ error: "Invalid botId" });
return;
}
const bot = await getBotForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
return;
}
await setBotStatusForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId, statusDuringAction, null);
await insertBotRuntimeEvent(deps.pool, {
tenantId: req.auth.tenantId,
botId: parsedParams.data.botId,
level: "info",
message: `Bot ${action} requested by dashboard user`,
metadata: {
userId: req.auth.userId,
action,
},
});
await enqueueBotControlAction(deps.queue, {
tenantId: req.auth.tenantId,
userId: req.auth.userId,
botId: parsedParams.data.botId,
action,
});
res.status(202).json({
accepted: true,
action,
botId: parsedParams.data.botId,
});
};
};
export const createBotRouter = (deps: BotRouterDependencies): Router => {
const router = expressRouter();
router.get("/", async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const bots = await listBotsForTenant(deps.pool, req.auth.tenantId);
res.json({ bots });
});
router.post("/", async (req, res) => {
if (!req.auth) {
res.status(401).json({ error: "Authentication required" });
return;
}
const parsedBody = addBotSchema.safeParse(req.body);
if (!parsedBody.success) {
res.status(400).json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
return;
}
try {
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
const encryptedToken = encryptToken(parsedBody.data.token, deps.tokenEncryptionKey);
const bot = await createOrUpdateBotForTenant(deps.pool, {
tenantId: req.auth.tenantId,
ownerUserId: req.auth.userId,
discordBotId: botIdentity.id,
displayName: parsedBody.data.displayName ?? botIdentity.displayName,
encryptedToken,
});
await insertBotRuntimeEvent(deps.pool, {
tenantId: req.auth.tenantId,
botId: bot.id,
level: "info",
message: "Bot credentials stored and encrypted",
metadata: {
byUser: req.auth.userId,
discordBotId: botIdentity.id,
},
});
res.status(201).json({ bot });
} catch (error) {
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
res.status(409).json({ error: "This Discord bot is already claimed by another tenant" });
return;
}
if (error instanceof Error && error.message === "Invalid Discord bot token") {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Unable to store bot" });
}
});
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
return router;
};
+45
View File
@@ -0,0 +1,45 @@
import { Queue } from "bullmq";
import type { Redis } from "ioredis";
import {
BOT_CONTROL_QUEUE,
BOT_CONTROL_QUEUE_PREFIX,
type BotControlAction,
type BotControlJob,
} from "@saas/shared";
export const createBotControlQueue = (redis: Redis): Queue<BotControlJob> => {
return new Queue<BotControlJob>(BOT_CONTROL_QUEUE, {
connection: redis,
prefix: BOT_CONTROL_QUEUE_PREFIX,
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 750,
},
removeOnComplete: 1000,
removeOnFail: 3000,
},
});
};
export const enqueueBotControlAction = async (
queue: Queue<BotControlJob>,
input: {
tenantId: string;
userId: string;
botId: string;
action: BotControlAction;
},
): Promise<void> => {
const payload: BotControlJob = {
tenantId: input.tenantId,
userId: input.userId,
botId: input.botId,
action: input.action,
requestedAt: new Date().toISOString(),
};
await queue.add(`${input.action}:${input.botId}:${Date.now()}`, payload);
};
+6
View File
@@ -0,0 +1,6 @@
export interface AuthSession {
userId: string;
tenantId: string;
discordUserId: string;
username: string;
}
+11
View File
@@ -0,0 +1,11 @@
import type { AuthSession } from "./auth.js";
declare global {
namespace Express {
interface Request {
auth?: AuthSession;
}
}
}
export {};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
FROM node:20-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/bot/package.json ./apps/bot/package.json
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /workspace
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY apps/bot ./apps/bot
RUN npm run build -w @saas/bot
FROM node:20-alpine AS runner
WORKDIR /workspace
ENV NODE_ENV=production
COPY --from=deps /workspace/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/bot/package.json ./apps/bot/package.json
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
COPY --from=builder /workspace/apps/bot/dist ./apps/bot/dist
CMD ["npm", "run", "start", "-w", "@saas/bot"]
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@saas/bot",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json && node ./scripts/copyLegacyLocales.mjs",
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.98",
"@saas/shared": "*",
"bullmq": "^5.21.2",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",
"ioredis": "^5.4.1",
"pg": "^8.13.3",
"pino": "^10.3.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/pg": "^8.11.11"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
const SOURCE_DIR = path.join(BOT_DIR, "src", "legacy", "i18n");
const TARGET_DIR = path.join(BOT_DIR, "dist", "legacy", "i18n");
if (!existsSync(SOURCE_DIR)) {
throw new Error(`[i18n] source locale directory not found: ${SOURCE_DIR}`);
}
mkdirSync(TARGET_DIR, { recursive: true });
for (const fileName of readdirSync(SOURCE_DIR)) {
if (!fileName.endsWith(".json")) {
continue;
}
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(TARGET_DIR, fileName));
}
+106
View File
@@ -0,0 +1,106 @@
import { config as loadEnv } from "dotenv";
import { z } from "zod";
import { SUPPORTED_LANGS } from "../legacy/types/command.js";
loadEnv();
const parseBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const optionalString = (value?: string): string | undefined => {
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const parseOptionalUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized;
};
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4100),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
DATABASE_SSL_REJECT_UNAUTHORIZED: z
.string()
.optional()
.default("true")
.transform(parseBoolean),
DATABASE_SSL_CA: z
.string()
.optional()
.transform((value) => {
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized.replace(/\\n/g, "\n");
}),
ALLOW_INSECURE_DB_SSL: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
PRESENCE_STREAM_URL: z
.string()
.url("PRESENCE_STREAM_URL must be a valid URL")
.optional()
.default("https://twitch.tv/discord"),
PREFIX: z.string().min(1).max(5).default("+"),
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
AUTO_DEPLOY_SLASH: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
LOG_LEVEL: z.string().trim().min(1).default("info"),
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
RATE_LIMIT_FAIL_OPEN: z
.string()
.optional()
.default("false")
.transform(parseBoolean),
ENABLE_LEADER_ELECTION: z
.string()
.optional()
.default("true")
.transform(parseBoolean),
});
const parsed = envSchema.parse(process.env);
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
throw new Error(
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
);
}
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
throw new Error("REDIS_URL is required when STATE_BACKEND=redis.");
}
export const env = parsed;
+17
View File
@@ -0,0 +1,17 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
export const createPgPool = (): Pool => {
const ssl = env.DATABASE_SSL
? {
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
ca: env.DATABASE_SSL_CA,
}
: undefined;
return new Pool({
connectionString: env.DATABASE_URL,
ssl,
});
};
+105
View File
@@ -0,0 +1,105 @@
import type { BotStatus } from "@saas/shared";
import type { Pool } from "pg";
export interface StoredBotCredentials {
id: string;
tenantId: string;
ownerUserId: string;
discordBotId: string;
displayName: string;
tokenCiphertext: string;
tokenIv: string;
tokenTag: string;
status: BotStatus;
}
const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials => {
return {
id: String(row.id),
tenantId: String(row.tenant_id),
ownerUserId: String(row.owner_user_id),
discordBotId: String(row.discord_bot_id),
displayName: String(row.display_name),
tokenCiphertext: String(row.token_ciphertext),
tokenIv: String(row.token_iv),
tokenTag: String(row.token_tag),
status: row.status as BotStatus,
};
};
export const listBotsToRestore = async (pool: Pool): Promise<StoredBotCredentials[]> => {
const result = await pool.query(
`
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
FROM bots
WHERE status IN ('running', 'starting')
ORDER BY created_at ASC
`,
);
return result.rows.map((row) => mapBotCredentials(row as Record<string, unknown>));
};
export const getBotCredentialsById = async (
pool: Pool,
botId: string,
): Promise<StoredBotCredentials | null> => {
const result = await pool.query(
`
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
FROM bots
WHERE id = $1
LIMIT 1
`,
[botId],
);
if (!result.rows[0]) {
return null;
}
return mapBotCredentials(result.rows[0] as Record<string, unknown>);
};
export const setBotStatusById = async (
pool: Pool,
botId: string,
status: BotStatus,
lastError: string | null = null,
): Promise<void> => {
await pool.query(
`
UPDATE bots
SET status = $2,
last_error = $3,
updated_at = NOW()
WHERE id = $1
`,
[botId, status, lastError],
);
};
export const insertRuntimeEvent = async (
pool: Pool,
input: {
tenantId: string;
botId: string;
level: "info" | "warn" | "error";
message: string;
metadata?: unknown;
},
): Promise<void> => {
await pool.query(
`
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[
input.tenantId,
input.botId,
input.level,
input.message,
JSON.stringify(input.metadata ?? {}),
],
);
};
+98
View File
@@ -0,0 +1,98 @@
import { createServer } from "node:http";
import { Redis } from "ioredis";
import { parseTokenEncryptionKey } from "@saas/shared";
import { env } from "./config/env.js";
import { createPgPool } from "./db/pool.js";
import { BotManager } from "./manager/BotManager.js";
import { createBotControlWorker } from "./queue/worker.js";
const bootstrap = async (): Promise<void> => {
const pgPool = createPgPool();
const redis = env.REDIS_URL
? new Redis(env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
})
: null;
await pgPool.query("SELECT 1");
if (redis) {
await redis.ping();
}
const manager = new BotManager(pgPool, parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY));
await manager.loadAndStartPersistedBots();
const worker = redis ? createBotControlWorker(redis.duplicate(), manager) : null;
if (worker) {
worker.on("completed", (job) => {
// eslint-disable-next-line no-console
console.log(`[bot] completed ${job.data.action} for bot ${job.data.botId}`);
});
worker.on("failed", (job, error) => {
// eslint-disable-next-line no-console
console.error(`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`, error);
});
} else {
// eslint-disable-next-line no-console
console.warn("[bot] REDIS_URL is not configured, bot control queue worker is disabled");
}
const healthServer = createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
const payload = JSON.stringify({
ok: true,
service: "bot",
runningBots: manager.getRunningCount(),
bots: manager.listRunningBots(),
timestamp: new Date().toISOString(),
});
res.writeHead(200, {
"Content-Type": "application/json",
});
res.end(payload);
return;
}
res.writeHead(404);
res.end();
});
healthServer.listen(env.PORT, () => {
// eslint-disable-next-line no-console
console.log(`[bot] health endpoint available on :${env.PORT}`);
});
const shutdown = async (signal: string): Promise<void> => {
// eslint-disable-next-line no-console
console.log(`[bot] shutdown requested (${signal})`);
healthServer.close(async () => {
await worker?.close().catch(() => undefined);
await manager.shutdown().catch(() => undefined);
await redis?.quit().catch(() => undefined);
await pgPool.end().catch(() => undefined);
process.exit(0);
});
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error("[bot] fatal startup error", error);
process.exit(1);
});
@@ -33,8 +33,8 @@ const parseOptionalUrl = (value: unknown): string | undefined => {
};
const envSchema = z.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
DISCORD_TOKEN: z.string().optional().default(""),
DISCORD_CLIENT_ID: z.string().optional().default(""),
DATABASE_URL: z
.string()
.trim()
@@ -11,6 +11,7 @@ import { registerGuildMemberRemove } from "./guildMemberRemove.js";
import { registerInteractionCreate } from "./interactionCreate.js";
import { registerLogRuntimeEvents } from "./logsRuntime.js";
import { registerMessageCreate } from "./messageCreate.js";
import type { RuntimeBotAuth } from "./ready.js";
import { registerClientReady } from "./ready.js";
export const registerEvents = (
@@ -23,6 +24,7 @@ export const registerEvents = (
registry: CommandRegistry,
services: AppFeatureServices,
leaderCoordinator: LeaderCoordinator,
runtimeAuth?: RuntimeBotAuth,
): void => {
registerMessageCreate(client, handlers.onPrefixMessage);
registerInteractionCreate(client, handlers.onSlashInteraction);
@@ -34,5 +36,5 @@ export const registerEvents = (
registerGuildCreate(client);
registerGuildDelete(client, services.memberMessageService, services.logEventService);
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator);
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator, runtimeAuth);
};
@@ -10,12 +10,18 @@ import type { PresenceService } from "../modules/presence/index.js";
const log = createScopedLogger("event:ready");
export interface RuntimeBotAuth {
token: string;
clientId: string;
}
export const registerClientReady = (
client: Client,
registry: CommandRegistry,
i18n: I18nService,
presenceService: PresenceService,
leaderCoordinator: LeaderCoordinator,
runtimeAuth?: RuntimeBotAuth,
): void => {
client.once(Events.ClientReady, async () => {
log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
@@ -39,11 +45,16 @@ export const registerClientReady = (
}
if (env.AUTO_DEPLOY_SLASH) {
if (!runtimeAuth) {
log.warn("slash sync skipped: runtime auth is missing for this bot instance");
return;
}
try {
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
const result = await deployApplicationCommands({
token: env.DISCORD_TOKEN,
clientId: env.DISCORD_CLIENT_ID,
token: runtimeAuth.token,
clientId: runtimeAuth.clientId,
registry,
i18n,
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),

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