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
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@saas/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}
+5
View File
@@ -0,0 +1,5 @@
export const BOT_CONTROL_QUEUE = "bot-control";
export const BOT_CONTROL_QUEUE_PREFIX = "discord-saas";
export const BOT_STATUS = ["stopped", "starting", "running", "stopping", "error"] as const;
export type BotStatus = (typeof BOT_STATUS)[number];
+4
View File
@@ -0,0 +1,4 @@
export * from "./constants.js";
export * from "./redisKeys.js";
export * from "./tokenCrypto.js";
export * from "./types.js";
+16
View File
@@ -0,0 +1,16 @@
const sanitizeSegment = (segment: string): string => segment.replace(/[^a-zA-Z0-9:_-]/g, "_");
export const tenantRedisKey = (tenantId: string, ...parts: string[]): string => {
const tail = parts.map(sanitizeSegment).join(":");
return tail.length > 0
? `tenant:${sanitizeSegment(tenantId)}:${tail}`
: `tenant:${sanitizeSegment(tenantId)}`;
};
export const tenantRateLimitKey = (tenantId: string, scope: string): string => {
return tenantRedisKey(tenantId, "ratelimit", scope);
};
export const tenantBotRedisKey = (tenantId: string, botId: string, ...parts: string[]): string => {
return tenantRedisKey(tenantId, "bot", botId, ...parts);
};
+45
View File
@@ -0,0 +1,45 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
export interface EncryptedToken {
ciphertext: string;
iv: string;
tag: string;
}
export const parseTokenEncryptionKey = (base64Key: string): Buffer => {
const key = Buffer.from(base64Key, "base64");
if (key.length !== 32) {
throw new Error("TOKEN_ENCRYPTION_KEY must decode to exactly 32 bytes");
}
return key;
};
export const encryptToken = (plainToken: string, key: Buffer): EncryptedToken => {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plainToken, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: tag.toString("base64"),
};
};
export const decryptToken = (encryptedToken: EncryptedToken, key: Buffer): string => {
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(encryptedToken.iv, "base64"));
decipher.setAuthTag(Buffer.from(encryptedToken.tag, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedToken.ciphertext, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
};
+54
View File
@@ -0,0 +1,54 @@
import type { BotStatus } from "./constants.js";
export interface TenantRecord {
id: string;
ownerUserId: string;
createdAt: string;
}
export interface UserRecord {
id: string;
tenantId: string;
discordUserId: string;
username: string;
avatarUrl: string | null;
role: "owner" | "member";
createdAt: string;
updatedAt: string;
}
export interface BotRecord {
id: string;
tenantId: string;
ownerUserId: string;
discordBotId: string;
displayName: string;
tokenCiphertext: string;
tokenIv: string;
tokenTag: string;
status: BotStatus;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export interface PublicBot {
id: string;
tenantId: string;
discordBotId: string;
displayName: string;
status: BotStatus;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export type BotControlAction = "start" | "stop" | "restart";
export interface BotControlJob {
tenantId: string;
userId: string;
botId: string;
action: BotControlAction;
requestedAt: string;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}