From 3063796eb00ef4533469dee944ae01160bb1d27d Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sat, 18 Apr 2026 01:39:12 +0200 Subject: [PATCH] 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 --- .dockerignore | 7 + .env.example | 53 +- .gitignore | 6 + AGENT.md | 140 +- Dockerfile | 33 - README.md | 263 +- apps/api/Dockerfile | 37 + apps/api/package.json | 31 + {scripts => apps/api/scripts}/migrate.mjs | 72 +- apps/api/src/auth/discordOAuth.ts | 122 + apps/api/src/auth/jwt.ts | 43 + apps/api/src/config/env.ts | 31 + apps/api/src/db/pool.ts | 14 + apps/api/src/db/repositories.ts | 328 ++ apps/api/src/index.ts | 125 + apps/api/src/middleware/auth.ts | 20 + apps/api/src/middleware/tenantRateLimit.ts | 47 + apps/api/src/routes/authRoutes.ts | 84 + apps/api/src/routes/botRoutes.ts | 156 + apps/api/src/services/botControlQueue.ts | 45 + apps/api/src/types/auth.ts | 6 + apps/api/src/types/express.d.ts | 11 + apps/api/tsconfig.json | 10 + apps/bot/Dockerfile | 34 + apps/bot/package.json | 26 + apps/bot/scripts/copyLegacyLocales.mjs | 22 + apps/bot/src/config/env.ts | 106 + apps/bot/src/db/pool.ts | 17 + apps/bot/src/db/repositories.ts | 105 + apps/bot/src/index.ts | 98 + {src => apps/bot/src/legacy}/app/bootstrap.ts | 0 {src => apps/bot/src/legacy}/app/container.ts | 0 .../bot/src/legacy}/commands/goodbye.ts | 0 {src => apps/bot/src/legacy}/commands/help.ts | 0 .../bot/src/legacy}/commands/index.ts | 0 {src => apps/bot/src/legacy}/commands/kiss.ts | 0 {src => apps/bot/src/legacy}/commands/logs.ts | 0 {src => apps/bot/src/legacy}/commands/ping.ts | 0 .../bot/src/legacy}/commands/presence.ts | 0 .../bot/src/legacy}/commands/welcome.ts | 0 {src => apps/bot/src/legacy}/config/env.ts | 4 +- .../src/legacy}/core/commands/argParser.ts | 0 .../legacy}/core/commands/defineCommand.ts | 0 .../bot/src/legacy}/core/commands/deploy.ts | 0 .../bot/src/legacy}/core/commands/registry.ts | 0 .../src/legacy}/core/commands/slashBuilder.ts | 0 .../bot/src/legacy}/core/commands/usage.ts | 0 .../core/discord/componentSessionRegistry.ts | 0 .../core/discord/replyMessageResolver.ts | 0 .../legacy}/core/execution/CommandExecutor.ts | 0 .../legacy}/core/execution/cooldownStore.ts | 0 .../src/legacy}/core/execution/dispatch.ts | 0 .../core/execution/globalRateLimitStore.ts | 0 .../execution/redisCommandJobPublisher.ts | 0 .../bot/src/legacy}/core/logging/logger.ts | 0 .../legacy}/core/runtime/leaderCoordinator.ts | 0 .../bot/src/legacy}/database/dbLifecycle.ts | 0 .../legacy}/database/stores/logEventStore.ts | 0 .../database/stores/memberMessageStore.ts | 0 .../legacy}/database/stores/presenceStore.ts | 0 .../bot/src/legacy}/events/guildCreate.ts | 0 .../bot/src/legacy}/events/guildDelete.ts | 0 .../bot/src/legacy}/events/guildMemberAdd.ts | 0 .../src/legacy}/events/guildMemberRemove.ts | 0 {src => apps/bot/src/legacy}/events/index.ts | 4 +- .../src/legacy}/events/interactionCreate.ts | 0 .../bot/src/legacy}/events/logsRuntime.ts | 0 .../bot/src/legacy}/events/messageCreate.ts | 0 {src => apps/bot/src/legacy}/events/ready.ts | 15 +- .../bot/src/legacy}/features/logs/catalog.ts | 0 .../src/legacy}/features/logs/commandPanel.ts | 0 .../src/legacy}/features/logs/repository.ts | 0 .../bot/src/legacy}/features/logs/service.ts | 0 .../features/memberMessages/commandPanel.ts | 0 .../features/memberMessages/imageRenderer.ts | 0 .../features/memberMessages/repository.ts | 0 .../features/memberMessages/service.ts | 0 .../legacy}/features/presence/commandPanel.ts | 0 .../legacy}/features/presence/repository.ts | 0 .../src/legacy}/features/presence/service.ts | 0 .../features/presence/templateVariables.ts | 0 .../src/legacy}/features/presence/types.ts | 0 .../handlers/commandExecutionContext.ts | 0 .../handlers/discordPermissionResolver.ts | 0 .../bot/src/legacy}/handlers/prefixHandler.ts | 0 .../bot/src/legacy}/handlers/replyAdapter.ts | 0 .../bot/src/legacy}/handlers/slashHandler.ts | 0 {src => apps/bot/src/legacy}/i18n/en.json | 0 {src => apps/bot/src/legacy}/i18n/es.json | 0 {src => apps/bot/src/legacy}/i18n/fr.json | 0 {src => apps/bot/src/legacy}/i18n/index.ts | 0 {src => apps/bot/src/legacy}/index.ts | 0 .../bot/src/legacy}/modules/help/command.ts | 0 .../bot/src/legacy}/modules/help/service.ts | 0 .../bot/src/legacy}/modules/logs/index.ts | 0 .../legacy}/modules/memberMessages/index.ts | 0 .../bot/src/legacy}/modules/presence/index.ts | 0 .../bot/src/legacy}/types/argParser.ts | 0 {src => apps/bot/src/legacy}/types/command.ts | 0 {src => apps/bot/src/legacy}/types/deploy.ts | 0 .../bot/src/legacy}/types/handlers.ts | 0 {src => apps/bot/src/legacy}/types/i18n.ts | 0 {src => apps/bot/src/legacy}/types/logs.ts | 0 .../bot/src/legacy}/types/memberMessages.ts | 0 .../bot/src/legacy}/types/presence.ts | 0 {src => apps/bot/src/legacy}/types/reply.ts | 0 .../src/legacy}/types/templateVariables.ts | 0 .../src/legacy}/utils/templateVariables.ts | 0 .../bot/src/legacy}/validators/logs.ts | 0 .../src/legacy}/validators/memberMessages.ts | 0 .../bot/src/legacy}/validators/presence.ts | 0 apps/bot/src/manager/BotManager.ts | 233 ++ apps/bot/src/queue/worker.ts | 36 + .../src/runtime/initializeLegacyBotRuntime.ts | 152 + .../src/runtime/stores/TenantLogEventStore.ts | 159 + .../stores/TenantMemberMessageStore.ts | 150 + .../src/runtime/stores/TenantPresenceStore.ts | 157 + apps/bot/tsconfig.json | 10 + apps/web/Dockerfile | 40 + apps/web/app/dashboard/page.tsx | 15 + apps/web/app/globals.css | 280 ++ apps/web/app/layout.tsx | 33 + apps/web/app/login/page.tsx | 19 + apps/web/app/page.tsx | 5 + apps/web/components/dashboard-client.tsx | 261 ++ apps/web/next-env.d.ts | 5 + apps/web/next.config.mjs | 7 + apps/web/package.json | 21 + apps/web/tsconfig.json | 31 + database/migrations/0004_saas_multitenant.sql | 220 ++ docker-compose.yml | 108 +- package-lock.json | 2978 +++++++++++++---- package.json | 44 +- packages/shared/package.json | 18 + packages/shared/src/constants.ts | 5 + packages/shared/src/index.ts | 4 + packages/shared/src/redisKeys.ts | 16 + packages/shared/src/tokenCrypto.ts | 45 + packages/shared/src/types.ts | 54 + packages/shared/tsconfig.json | 9 + scripts/build.mjs | 45 - tests/argTokenizer.test.ts | 19 - tests/defineCommand.test.ts | 60 - tests/executionStores.test.ts | 65 - tests/logsTypes.test.ts | 44 - tests/memberMessagesTypes.test.ts | 28 - tests/presenceTypes.test.ts | 54 - tests/templateVariables.test.ts | 47 - tsconfig.base.json | 16 + tsconfig.json | 27 +- 150 files changed, 6248 insertions(+), 1387 deletions(-) delete mode 100644 Dockerfile create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/package.json rename {scripts => apps/api/scripts}/migrate.mjs (62%) create mode 100644 apps/api/src/auth/discordOAuth.ts create mode 100644 apps/api/src/auth/jwt.ts create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/db/pool.ts create mode 100644 apps/api/src/db/repositories.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/middleware/auth.ts create mode 100644 apps/api/src/middleware/tenantRateLimit.ts create mode 100644 apps/api/src/routes/authRoutes.ts create mode 100644 apps/api/src/routes/botRoutes.ts create mode 100644 apps/api/src/services/botControlQueue.ts create mode 100644 apps/api/src/types/auth.ts create mode 100644 apps/api/src/types/express.d.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/bot/Dockerfile create mode 100644 apps/bot/package.json create mode 100644 apps/bot/scripts/copyLegacyLocales.mjs create mode 100644 apps/bot/src/config/env.ts create mode 100644 apps/bot/src/db/pool.ts create mode 100644 apps/bot/src/db/repositories.ts create mode 100644 apps/bot/src/index.ts rename {src => apps/bot/src/legacy}/app/bootstrap.ts (100%) rename {src => apps/bot/src/legacy}/app/container.ts (100%) rename {src => apps/bot/src/legacy}/commands/goodbye.ts (100%) rename {src => apps/bot/src/legacy}/commands/help.ts (100%) rename {src => apps/bot/src/legacy}/commands/index.ts (100%) rename {src => apps/bot/src/legacy}/commands/kiss.ts (100%) rename {src => apps/bot/src/legacy}/commands/logs.ts (100%) rename {src => apps/bot/src/legacy}/commands/ping.ts (100%) rename {src => apps/bot/src/legacy}/commands/presence.ts (100%) rename {src => apps/bot/src/legacy}/commands/welcome.ts (100%) rename {src => apps/bot/src/legacy}/config/env.ts (95%) rename {src => apps/bot/src/legacy}/core/commands/argParser.ts (100%) rename {src => apps/bot/src/legacy}/core/commands/defineCommand.ts (100%) rename {src => apps/bot/src/legacy}/core/commands/deploy.ts (100%) rename {src => apps/bot/src/legacy}/core/commands/registry.ts (100%) rename {src => apps/bot/src/legacy}/core/commands/slashBuilder.ts (100%) rename {src => apps/bot/src/legacy}/core/commands/usage.ts (100%) rename {src => apps/bot/src/legacy}/core/discord/componentSessionRegistry.ts (100%) rename {src => apps/bot/src/legacy}/core/discord/replyMessageResolver.ts (100%) rename {src => apps/bot/src/legacy}/core/execution/CommandExecutor.ts (100%) rename {src => apps/bot/src/legacy}/core/execution/cooldownStore.ts (100%) rename {src => apps/bot/src/legacy}/core/execution/dispatch.ts (100%) rename {src => apps/bot/src/legacy}/core/execution/globalRateLimitStore.ts (100%) rename {src => apps/bot/src/legacy}/core/execution/redisCommandJobPublisher.ts (100%) rename {src => apps/bot/src/legacy}/core/logging/logger.ts (100%) rename {src => apps/bot/src/legacy}/core/runtime/leaderCoordinator.ts (100%) rename {src => apps/bot/src/legacy}/database/dbLifecycle.ts (100%) rename {src => apps/bot/src/legacy}/database/stores/logEventStore.ts (100%) rename {src => apps/bot/src/legacy}/database/stores/memberMessageStore.ts (100%) rename {src => apps/bot/src/legacy}/database/stores/presenceStore.ts (100%) rename {src => apps/bot/src/legacy}/events/guildCreate.ts (100%) rename {src => apps/bot/src/legacy}/events/guildDelete.ts (100%) rename {src => apps/bot/src/legacy}/events/guildMemberAdd.ts (100%) rename {src => apps/bot/src/legacy}/events/guildMemberRemove.ts (100%) rename {src => apps/bot/src/legacy}/events/index.ts (93%) rename {src => apps/bot/src/legacy}/events/interactionCreate.ts (100%) rename {src => apps/bot/src/legacy}/events/logsRuntime.ts (100%) rename {src => apps/bot/src/legacy}/events/messageCreate.ts (100%) rename {src => apps/bot/src/legacy}/events/ready.ts (87%) rename {src => apps/bot/src/legacy}/features/logs/catalog.ts (100%) rename {src => apps/bot/src/legacy}/features/logs/commandPanel.ts (100%) rename {src => apps/bot/src/legacy}/features/logs/repository.ts (100%) rename {src => apps/bot/src/legacy}/features/logs/service.ts (100%) rename {src => apps/bot/src/legacy}/features/memberMessages/commandPanel.ts (100%) rename {src => apps/bot/src/legacy}/features/memberMessages/imageRenderer.ts (100%) rename {src => apps/bot/src/legacy}/features/memberMessages/repository.ts (100%) rename {src => apps/bot/src/legacy}/features/memberMessages/service.ts (100%) rename {src => apps/bot/src/legacy}/features/presence/commandPanel.ts (100%) rename {src => apps/bot/src/legacy}/features/presence/repository.ts (100%) rename {src => apps/bot/src/legacy}/features/presence/service.ts (100%) rename {src => apps/bot/src/legacy}/features/presence/templateVariables.ts (100%) rename {src => apps/bot/src/legacy}/features/presence/types.ts (100%) rename {src => apps/bot/src/legacy}/handlers/commandExecutionContext.ts (100%) rename {src => apps/bot/src/legacy}/handlers/discordPermissionResolver.ts (100%) rename {src => apps/bot/src/legacy}/handlers/prefixHandler.ts (100%) rename {src => apps/bot/src/legacy}/handlers/replyAdapter.ts (100%) rename {src => apps/bot/src/legacy}/handlers/slashHandler.ts (100%) rename {src => apps/bot/src/legacy}/i18n/en.json (100%) rename {src => apps/bot/src/legacy}/i18n/es.json (100%) rename {src => apps/bot/src/legacy}/i18n/fr.json (100%) rename {src => apps/bot/src/legacy}/i18n/index.ts (100%) rename {src => apps/bot/src/legacy}/index.ts (100%) rename {src => apps/bot/src/legacy}/modules/help/command.ts (100%) rename {src => apps/bot/src/legacy}/modules/help/service.ts (100%) rename {src => apps/bot/src/legacy}/modules/logs/index.ts (100%) rename {src => apps/bot/src/legacy}/modules/memberMessages/index.ts (100%) rename {src => apps/bot/src/legacy}/modules/presence/index.ts (100%) rename {src => apps/bot/src/legacy}/types/argParser.ts (100%) rename {src => apps/bot/src/legacy}/types/command.ts (100%) rename {src => apps/bot/src/legacy}/types/deploy.ts (100%) rename {src => apps/bot/src/legacy}/types/handlers.ts (100%) rename {src => apps/bot/src/legacy}/types/i18n.ts (100%) rename {src => apps/bot/src/legacy}/types/logs.ts (100%) rename {src => apps/bot/src/legacy}/types/memberMessages.ts (100%) rename {src => apps/bot/src/legacy}/types/presence.ts (100%) rename {src => apps/bot/src/legacy}/types/reply.ts (100%) rename {src => apps/bot/src/legacy}/types/templateVariables.ts (100%) rename {src => apps/bot/src/legacy}/utils/templateVariables.ts (100%) rename {src => apps/bot/src/legacy}/validators/logs.ts (100%) rename {src => apps/bot/src/legacy}/validators/memberMessages.ts (100%) rename {src => apps/bot/src/legacy}/validators/presence.ts (100%) create mode 100644 apps/bot/src/manager/BotManager.ts create mode 100644 apps/bot/src/queue/worker.ts create mode 100644 apps/bot/src/runtime/initializeLegacyBotRuntime.ts create mode 100644 apps/bot/src/runtime/stores/TenantLogEventStore.ts create mode 100644 apps/bot/src/runtime/stores/TenantMemberMessageStore.ts create mode 100644 apps/bot/src/runtime/stores/TenantPresenceStore.ts create mode 100644 apps/bot/tsconfig.json create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/components/dashboard-client.tsx create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/tsconfig.json create mode 100644 database/migrations/0004_saas_multitenant.sql create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/redisKeys.ts create mode 100644 packages/shared/src/tokenCrypto.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json delete mode 100644 scripts/build.mjs delete mode 100644 tests/argTokenizer.test.ts delete mode 100644 tests/defineCommand.test.ts delete mode 100644 tests/executionStores.test.ts delete mode 100644 tests/logsTypes.test.ts delete mode 100644 tests/memberMessagesTypes.test.ts delete mode 100644 tests/presenceTypes.test.ts delete mode 100644 tests/templateVariables.test.ts create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore index 9e29168..59eb023 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,12 @@ node_modules build +dist +.next +apps/*/dist +apps/*/.next +packages/*/dist +*.tsbuildinfo +**/*.tsbuildinfo .git .gitignore .env diff --git a/.env.example b/.env.example index cfd1a76..95c00d0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 7c14924..6d24778 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ node_modules/ build/ +dist/ +.next/ +apps/*/dist/ +apps/*/.next/ +packages/*/dist/ +*.tsbuildinfo .env .env.* !.env.example diff --git a/AGENT.md b/AGENT.md index d732355..2bb8a0e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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`), 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//`. -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). diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index fc9477e..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index 58ab6c6..3210390 100644 --- a/README.md +++ b/README.md @@ -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: - 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 +```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 +``` -## Docker Deployment (2 Bots + PostgreSQL) +## Architecture Runtime -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: - docker compose up -d --build -4. Stop stack: - docker compose down +### 1) API (`apps/api`) -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` +- 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}:...`) -Both bots use the same PostgreSQL service, and data remains isolated per bot through `bot_id` keys. +### 2) Bot Manager (`apps/bot`) -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. +- Charge les bots à relancer depuis PostgreSQL au démarrage +- Maintient une map en mémoire: -## Architecture +```ts +Map +``` -- `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 +- 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`) -## Included Commands +### 3) Web (`apps/web`) -- `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 +- Pages principales: + - `/login` + - `/dashboard` +- Dashboard utilisateur: + - Ajouter un bot (token) + - Voir la liste des bots du tenant + - `start / stop / restart` -## Adding A Command +## Schéma PostgreSQL (core SaaS) -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//...` and keep `src/commands/*` as wrappers only -5. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically +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 +``` + +2. Générer une clé de chiffrement valide (32 bytes base64): + +```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 +``` + +5. Dashboard: + +- `http://localhost:3000` + +API: + +- `http://localhost:4000/health` + +Bot manager health: + +- `http://localhost:4100/health` + +## Notes Scalabilité + +- 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 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..6386c6d --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..6961274 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/scripts/migrate.mjs b/apps/api/scripts/migrate.mjs similarity index 62% rename from scripts/migrate.mjs rename to apps/api/scripts/migrate.mjs index b8b2434..c4d917c 100644 --- a/scripts/migrate.mjs +++ b/apps/api/scripts/migrate.mjs @@ -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(); diff --git a/apps/api/src/auth/discordOAuth.ts b/apps/api/src/auth/discordOAuth.ts new file mode 100644 index 0000000..962cc4b --- /dev/null +++ b/apps/api/src/auth/discordOAuth.ts @@ -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 => { + 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 => { + 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, + }; +}; diff --git a/apps/api/src/auth/jwt.ts b/apps/api/src/auth/jwt.ts new file mode 100644 index 0000000..1853adc --- /dev/null +++ b/apps/api/src/auth/jwt.ts @@ -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 => { + 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 => { + const verified = await jwtVerify(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, + }; +}; diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..d32a4ce --- /dev/null +++ b/apps/api/src/config/env.ts @@ -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); diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts new file mode 100644 index 0000000..a04cf42 --- /dev/null +++ b/apps/api/src/db/pool.ts @@ -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, + }); +}; diff --git a/apps/api/src/db/repositories.ts b/apps/api/src/db/repositories.ts new file mode 100644 index 0000000..9c53f70 --- /dev/null +++ b/apps/api/src/db/repositories.ts @@ -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): 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): 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 => { + 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); + } + + 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); + + 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 => { + 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); +}; + +export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise => { + 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)); +}; + +export const getBotForTenant = async ( + pool: Pool, + tenantId: string, + botId: string, +): Promise => { + 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); +}; + +export const createOrUpdateBotForTenant = async ( + pool: Pool, + input: CreateOrUpdateBotInput, +): Promise => { + 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); + } 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 => { + 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 => { + 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 ?? {}), + ], + ); +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..537cc11 --- /dev/null +++ b/apps/api/src/index.ts @@ -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 => { + 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 => { + // 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); +}); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..79bb0dc --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -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" }); + } +}; diff --git a/apps/api/src/middleware/tenantRateLimit.ts b/apps/api/src/middleware/tenantRateLimit.ts new file mode 100644 index 0000000..818772d --- /dev/null +++ b/apps/api/src/middleware/tenantRateLimit.ts @@ -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" }); + } + }; +}; diff --git a/apps/api/src/routes/authRoutes.ts b/apps/api/src/routes/authRoutes.ts new file mode 100644 index 0000000..8e1cb5f --- /dev/null +++ b/apps/api/src/routes/authRoutes.ts @@ -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; +}; diff --git a/apps/api/src/routes/botRoutes.ts b/apps/api/src/routes/botRoutes.ts new file mode 100644 index 0000000..d88cd48 --- /dev/null +++ b/apps/api/src/routes/botRoutes.ts @@ -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; + 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; +}; diff --git a/apps/api/src/services/botControlQueue.ts b/apps/api/src/services/botControlQueue.ts new file mode 100644 index 0000000..8f4ba6c --- /dev/null +++ b/apps/api/src/services/botControlQueue.ts @@ -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 => { + return new Queue(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, + input: { + tenantId: string; + userId: string; + botId: string; + action: BotControlAction; + }, +): Promise => { + 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); +}; diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts new file mode 100644 index 0000000..1e199d0 --- /dev/null +++ b/apps/api/src/types/auth.ts @@ -0,0 +1,6 @@ +export interface AuthSession { + userId: string; + tenantId: string; + discordUserId: string; + username: string; +} diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts new file mode 100644 index 0000000..94d19eb --- /dev/null +++ b/apps/api/src/types/express.d.ts @@ -0,0 +1,11 @@ +import type { AuthSession } from "./auth.js"; + +declare global { + namespace Express { + interface Request { + auth?: AuthSession; + } + } +} + +export {}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..b0f4e56 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile new file mode 100644 index 0000000..9f91909 --- /dev/null +++ b/apps/bot/Dockerfile @@ -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"] diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..0a65094 --- /dev/null +++ b/apps/bot/package.json @@ -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" + } +} diff --git a/apps/bot/scripts/copyLegacyLocales.mjs b/apps/bot/scripts/copyLegacyLocales.mjs new file mode 100644 index 0000000..a773bfb --- /dev/null +++ b/apps/bot/scripts/copyLegacyLocales.mjs @@ -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)); +} \ No newline at end of file diff --git a/apps/bot/src/config/env.ts b/apps/bot/src/config/env.ts new file mode 100644 index 0000000..e79a591 --- /dev/null +++ b/apps/bot/src/config/env.ts @@ -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; diff --git a/apps/bot/src/db/pool.ts b/apps/bot/src/db/pool.ts new file mode 100644 index 0000000..f7fac61 --- /dev/null +++ b/apps/bot/src/db/pool.ts @@ -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, + }); +}; diff --git a/apps/bot/src/db/repositories.ts b/apps/bot/src/db/repositories.ts new file mode 100644 index 0000000..9ee570e --- /dev/null +++ b/apps/bot/src/db/repositories.ts @@ -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): 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 => { + 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)); +}; + +export const getBotCredentialsById = async ( + pool: Pool, + botId: string, +): Promise => { + 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); +}; + +export const setBotStatusById = async ( + pool: Pool, + botId: string, + status: BotStatus, + lastError: string | null = null, +): Promise => { + 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 => { + 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 ?? {}), + ], + ); +}; diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..f239c9e --- /dev/null +++ b/apps/bot/src/index.ts @@ -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 => { + 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 => { + // 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); +}); diff --git a/src/app/bootstrap.ts b/apps/bot/src/legacy/app/bootstrap.ts similarity index 100% rename from src/app/bootstrap.ts rename to apps/bot/src/legacy/app/bootstrap.ts diff --git a/src/app/container.ts b/apps/bot/src/legacy/app/container.ts similarity index 100% rename from src/app/container.ts rename to apps/bot/src/legacy/app/container.ts diff --git a/src/commands/goodbye.ts b/apps/bot/src/legacy/commands/goodbye.ts similarity index 100% rename from src/commands/goodbye.ts rename to apps/bot/src/legacy/commands/goodbye.ts diff --git a/src/commands/help.ts b/apps/bot/src/legacy/commands/help.ts similarity index 100% rename from src/commands/help.ts rename to apps/bot/src/legacy/commands/help.ts diff --git a/src/commands/index.ts b/apps/bot/src/legacy/commands/index.ts similarity index 100% rename from src/commands/index.ts rename to apps/bot/src/legacy/commands/index.ts diff --git a/src/commands/kiss.ts b/apps/bot/src/legacy/commands/kiss.ts similarity index 100% rename from src/commands/kiss.ts rename to apps/bot/src/legacy/commands/kiss.ts diff --git a/src/commands/logs.ts b/apps/bot/src/legacy/commands/logs.ts similarity index 100% rename from src/commands/logs.ts rename to apps/bot/src/legacy/commands/logs.ts diff --git a/src/commands/ping.ts b/apps/bot/src/legacy/commands/ping.ts similarity index 100% rename from src/commands/ping.ts rename to apps/bot/src/legacy/commands/ping.ts diff --git a/src/commands/presence.ts b/apps/bot/src/legacy/commands/presence.ts similarity index 100% rename from src/commands/presence.ts rename to apps/bot/src/legacy/commands/presence.ts diff --git a/src/commands/welcome.ts b/apps/bot/src/legacy/commands/welcome.ts similarity index 100% rename from src/commands/welcome.ts rename to apps/bot/src/legacy/commands/welcome.ts diff --git a/src/config/env.ts b/apps/bot/src/legacy/config/env.ts similarity index 95% rename from src/config/env.ts rename to apps/bot/src/legacy/config/env.ts index b1217bd..0043aa7 100644 --- a/src/config/env.ts +++ b/apps/bot/src/legacy/config/env.ts @@ -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() diff --git a/src/core/commands/argParser.ts b/apps/bot/src/legacy/core/commands/argParser.ts similarity index 100% rename from src/core/commands/argParser.ts rename to apps/bot/src/legacy/core/commands/argParser.ts diff --git a/src/core/commands/defineCommand.ts b/apps/bot/src/legacy/core/commands/defineCommand.ts similarity index 100% rename from src/core/commands/defineCommand.ts rename to apps/bot/src/legacy/core/commands/defineCommand.ts diff --git a/src/core/commands/deploy.ts b/apps/bot/src/legacy/core/commands/deploy.ts similarity index 100% rename from src/core/commands/deploy.ts rename to apps/bot/src/legacy/core/commands/deploy.ts diff --git a/src/core/commands/registry.ts b/apps/bot/src/legacy/core/commands/registry.ts similarity index 100% rename from src/core/commands/registry.ts rename to apps/bot/src/legacy/core/commands/registry.ts diff --git a/src/core/commands/slashBuilder.ts b/apps/bot/src/legacy/core/commands/slashBuilder.ts similarity index 100% rename from src/core/commands/slashBuilder.ts rename to apps/bot/src/legacy/core/commands/slashBuilder.ts diff --git a/src/core/commands/usage.ts b/apps/bot/src/legacy/core/commands/usage.ts similarity index 100% rename from src/core/commands/usage.ts rename to apps/bot/src/legacy/core/commands/usage.ts diff --git a/src/core/discord/componentSessionRegistry.ts b/apps/bot/src/legacy/core/discord/componentSessionRegistry.ts similarity index 100% rename from src/core/discord/componentSessionRegistry.ts rename to apps/bot/src/legacy/core/discord/componentSessionRegistry.ts diff --git a/src/core/discord/replyMessageResolver.ts b/apps/bot/src/legacy/core/discord/replyMessageResolver.ts similarity index 100% rename from src/core/discord/replyMessageResolver.ts rename to apps/bot/src/legacy/core/discord/replyMessageResolver.ts diff --git a/src/core/execution/CommandExecutor.ts b/apps/bot/src/legacy/core/execution/CommandExecutor.ts similarity index 100% rename from src/core/execution/CommandExecutor.ts rename to apps/bot/src/legacy/core/execution/CommandExecutor.ts diff --git a/src/core/execution/cooldownStore.ts b/apps/bot/src/legacy/core/execution/cooldownStore.ts similarity index 100% rename from src/core/execution/cooldownStore.ts rename to apps/bot/src/legacy/core/execution/cooldownStore.ts diff --git a/src/core/execution/dispatch.ts b/apps/bot/src/legacy/core/execution/dispatch.ts similarity index 100% rename from src/core/execution/dispatch.ts rename to apps/bot/src/legacy/core/execution/dispatch.ts diff --git a/src/core/execution/globalRateLimitStore.ts b/apps/bot/src/legacy/core/execution/globalRateLimitStore.ts similarity index 100% rename from src/core/execution/globalRateLimitStore.ts rename to apps/bot/src/legacy/core/execution/globalRateLimitStore.ts diff --git a/src/core/execution/redisCommandJobPublisher.ts b/apps/bot/src/legacy/core/execution/redisCommandJobPublisher.ts similarity index 100% rename from src/core/execution/redisCommandJobPublisher.ts rename to apps/bot/src/legacy/core/execution/redisCommandJobPublisher.ts diff --git a/src/core/logging/logger.ts b/apps/bot/src/legacy/core/logging/logger.ts similarity index 100% rename from src/core/logging/logger.ts rename to apps/bot/src/legacy/core/logging/logger.ts diff --git a/src/core/runtime/leaderCoordinator.ts b/apps/bot/src/legacy/core/runtime/leaderCoordinator.ts similarity index 100% rename from src/core/runtime/leaderCoordinator.ts rename to apps/bot/src/legacy/core/runtime/leaderCoordinator.ts diff --git a/src/database/dbLifecycle.ts b/apps/bot/src/legacy/database/dbLifecycle.ts similarity index 100% rename from src/database/dbLifecycle.ts rename to apps/bot/src/legacy/database/dbLifecycle.ts diff --git a/src/database/stores/logEventStore.ts b/apps/bot/src/legacy/database/stores/logEventStore.ts similarity index 100% rename from src/database/stores/logEventStore.ts rename to apps/bot/src/legacy/database/stores/logEventStore.ts diff --git a/src/database/stores/memberMessageStore.ts b/apps/bot/src/legacy/database/stores/memberMessageStore.ts similarity index 100% rename from src/database/stores/memberMessageStore.ts rename to apps/bot/src/legacy/database/stores/memberMessageStore.ts diff --git a/src/database/stores/presenceStore.ts b/apps/bot/src/legacy/database/stores/presenceStore.ts similarity index 100% rename from src/database/stores/presenceStore.ts rename to apps/bot/src/legacy/database/stores/presenceStore.ts diff --git a/src/events/guildCreate.ts b/apps/bot/src/legacy/events/guildCreate.ts similarity index 100% rename from src/events/guildCreate.ts rename to apps/bot/src/legacy/events/guildCreate.ts diff --git a/src/events/guildDelete.ts b/apps/bot/src/legacy/events/guildDelete.ts similarity index 100% rename from src/events/guildDelete.ts rename to apps/bot/src/legacy/events/guildDelete.ts diff --git a/src/events/guildMemberAdd.ts b/apps/bot/src/legacy/events/guildMemberAdd.ts similarity index 100% rename from src/events/guildMemberAdd.ts rename to apps/bot/src/legacy/events/guildMemberAdd.ts diff --git a/src/events/guildMemberRemove.ts b/apps/bot/src/legacy/events/guildMemberRemove.ts similarity index 100% rename from src/events/guildMemberRemove.ts rename to apps/bot/src/legacy/events/guildMemberRemove.ts diff --git a/src/events/index.ts b/apps/bot/src/legacy/events/index.ts similarity index 93% rename from src/events/index.ts rename to apps/bot/src/legacy/events/index.ts index f13de69..d574145 100644 --- a/src/events/index.ts +++ b/apps/bot/src/legacy/events/index.ts @@ -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); }; diff --git a/src/events/interactionCreate.ts b/apps/bot/src/legacy/events/interactionCreate.ts similarity index 100% rename from src/events/interactionCreate.ts rename to apps/bot/src/legacy/events/interactionCreate.ts diff --git a/src/events/logsRuntime.ts b/apps/bot/src/legacy/events/logsRuntime.ts similarity index 100% rename from src/events/logsRuntime.ts rename to apps/bot/src/legacy/events/logsRuntime.ts diff --git a/src/events/messageCreate.ts b/apps/bot/src/legacy/events/messageCreate.ts similarity index 100% rename from src/events/messageCreate.ts rename to apps/bot/src/legacy/events/messageCreate.ts diff --git a/src/events/ready.ts b/apps/bot/src/legacy/events/ready.ts similarity index 87% rename from src/events/ready.ts rename to apps/bot/src/legacy/events/ready.ts index 9c77731..5afdf03 100644 --- a/src/events/ready.ts +++ b/apps/bot/src/legacy/events/ready.ts @@ -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 } : {}), diff --git a/src/features/logs/catalog.ts b/apps/bot/src/legacy/features/logs/catalog.ts similarity index 100% rename from src/features/logs/catalog.ts rename to apps/bot/src/legacy/features/logs/catalog.ts diff --git a/src/features/logs/commandPanel.ts b/apps/bot/src/legacy/features/logs/commandPanel.ts similarity index 100% rename from src/features/logs/commandPanel.ts rename to apps/bot/src/legacy/features/logs/commandPanel.ts diff --git a/src/features/logs/repository.ts b/apps/bot/src/legacy/features/logs/repository.ts similarity index 100% rename from src/features/logs/repository.ts rename to apps/bot/src/legacy/features/logs/repository.ts diff --git a/src/features/logs/service.ts b/apps/bot/src/legacy/features/logs/service.ts similarity index 100% rename from src/features/logs/service.ts rename to apps/bot/src/legacy/features/logs/service.ts diff --git a/src/features/memberMessages/commandPanel.ts b/apps/bot/src/legacy/features/memberMessages/commandPanel.ts similarity index 100% rename from src/features/memberMessages/commandPanel.ts rename to apps/bot/src/legacy/features/memberMessages/commandPanel.ts diff --git a/src/features/memberMessages/imageRenderer.ts b/apps/bot/src/legacy/features/memberMessages/imageRenderer.ts similarity index 100% rename from src/features/memberMessages/imageRenderer.ts rename to apps/bot/src/legacy/features/memberMessages/imageRenderer.ts diff --git a/src/features/memberMessages/repository.ts b/apps/bot/src/legacy/features/memberMessages/repository.ts similarity index 100% rename from src/features/memberMessages/repository.ts rename to apps/bot/src/legacy/features/memberMessages/repository.ts diff --git a/src/features/memberMessages/service.ts b/apps/bot/src/legacy/features/memberMessages/service.ts similarity index 100% rename from src/features/memberMessages/service.ts rename to apps/bot/src/legacy/features/memberMessages/service.ts diff --git a/src/features/presence/commandPanel.ts b/apps/bot/src/legacy/features/presence/commandPanel.ts similarity index 100% rename from src/features/presence/commandPanel.ts rename to apps/bot/src/legacy/features/presence/commandPanel.ts diff --git a/src/features/presence/repository.ts b/apps/bot/src/legacy/features/presence/repository.ts similarity index 100% rename from src/features/presence/repository.ts rename to apps/bot/src/legacy/features/presence/repository.ts diff --git a/src/features/presence/service.ts b/apps/bot/src/legacy/features/presence/service.ts similarity index 100% rename from src/features/presence/service.ts rename to apps/bot/src/legacy/features/presence/service.ts diff --git a/src/features/presence/templateVariables.ts b/apps/bot/src/legacy/features/presence/templateVariables.ts similarity index 100% rename from src/features/presence/templateVariables.ts rename to apps/bot/src/legacy/features/presence/templateVariables.ts diff --git a/src/features/presence/types.ts b/apps/bot/src/legacy/features/presence/types.ts similarity index 100% rename from src/features/presence/types.ts rename to apps/bot/src/legacy/features/presence/types.ts diff --git a/src/handlers/commandExecutionContext.ts b/apps/bot/src/legacy/handlers/commandExecutionContext.ts similarity index 100% rename from src/handlers/commandExecutionContext.ts rename to apps/bot/src/legacy/handlers/commandExecutionContext.ts diff --git a/src/handlers/discordPermissionResolver.ts b/apps/bot/src/legacy/handlers/discordPermissionResolver.ts similarity index 100% rename from src/handlers/discordPermissionResolver.ts rename to apps/bot/src/legacy/handlers/discordPermissionResolver.ts diff --git a/src/handlers/prefixHandler.ts b/apps/bot/src/legacy/handlers/prefixHandler.ts similarity index 100% rename from src/handlers/prefixHandler.ts rename to apps/bot/src/legacy/handlers/prefixHandler.ts diff --git a/src/handlers/replyAdapter.ts b/apps/bot/src/legacy/handlers/replyAdapter.ts similarity index 100% rename from src/handlers/replyAdapter.ts rename to apps/bot/src/legacy/handlers/replyAdapter.ts diff --git a/src/handlers/slashHandler.ts b/apps/bot/src/legacy/handlers/slashHandler.ts similarity index 100% rename from src/handlers/slashHandler.ts rename to apps/bot/src/legacy/handlers/slashHandler.ts diff --git a/src/i18n/en.json b/apps/bot/src/legacy/i18n/en.json similarity index 100% rename from src/i18n/en.json rename to apps/bot/src/legacy/i18n/en.json diff --git a/src/i18n/es.json b/apps/bot/src/legacy/i18n/es.json similarity index 100% rename from src/i18n/es.json rename to apps/bot/src/legacy/i18n/es.json diff --git a/src/i18n/fr.json b/apps/bot/src/legacy/i18n/fr.json similarity index 100% rename from src/i18n/fr.json rename to apps/bot/src/legacy/i18n/fr.json diff --git a/src/i18n/index.ts b/apps/bot/src/legacy/i18n/index.ts similarity index 100% rename from src/i18n/index.ts rename to apps/bot/src/legacy/i18n/index.ts diff --git a/src/index.ts b/apps/bot/src/legacy/index.ts similarity index 100% rename from src/index.ts rename to apps/bot/src/legacy/index.ts diff --git a/src/modules/help/command.ts b/apps/bot/src/legacy/modules/help/command.ts similarity index 100% rename from src/modules/help/command.ts rename to apps/bot/src/legacy/modules/help/command.ts diff --git a/src/modules/help/service.ts b/apps/bot/src/legacy/modules/help/service.ts similarity index 100% rename from src/modules/help/service.ts rename to apps/bot/src/legacy/modules/help/service.ts diff --git a/src/modules/logs/index.ts b/apps/bot/src/legacy/modules/logs/index.ts similarity index 100% rename from src/modules/logs/index.ts rename to apps/bot/src/legacy/modules/logs/index.ts diff --git a/src/modules/memberMessages/index.ts b/apps/bot/src/legacy/modules/memberMessages/index.ts similarity index 100% rename from src/modules/memberMessages/index.ts rename to apps/bot/src/legacy/modules/memberMessages/index.ts diff --git a/src/modules/presence/index.ts b/apps/bot/src/legacy/modules/presence/index.ts similarity index 100% rename from src/modules/presence/index.ts rename to apps/bot/src/legacy/modules/presence/index.ts diff --git a/src/types/argParser.ts b/apps/bot/src/legacy/types/argParser.ts similarity index 100% rename from src/types/argParser.ts rename to apps/bot/src/legacy/types/argParser.ts diff --git a/src/types/command.ts b/apps/bot/src/legacy/types/command.ts similarity index 100% rename from src/types/command.ts rename to apps/bot/src/legacy/types/command.ts diff --git a/src/types/deploy.ts b/apps/bot/src/legacy/types/deploy.ts similarity index 100% rename from src/types/deploy.ts rename to apps/bot/src/legacy/types/deploy.ts diff --git a/src/types/handlers.ts b/apps/bot/src/legacy/types/handlers.ts similarity index 100% rename from src/types/handlers.ts rename to apps/bot/src/legacy/types/handlers.ts diff --git a/src/types/i18n.ts b/apps/bot/src/legacy/types/i18n.ts similarity index 100% rename from src/types/i18n.ts rename to apps/bot/src/legacy/types/i18n.ts diff --git a/src/types/logs.ts b/apps/bot/src/legacy/types/logs.ts similarity index 100% rename from src/types/logs.ts rename to apps/bot/src/legacy/types/logs.ts diff --git a/src/types/memberMessages.ts b/apps/bot/src/legacy/types/memberMessages.ts similarity index 100% rename from src/types/memberMessages.ts rename to apps/bot/src/legacy/types/memberMessages.ts diff --git a/src/types/presence.ts b/apps/bot/src/legacy/types/presence.ts similarity index 100% rename from src/types/presence.ts rename to apps/bot/src/legacy/types/presence.ts diff --git a/src/types/reply.ts b/apps/bot/src/legacy/types/reply.ts similarity index 100% rename from src/types/reply.ts rename to apps/bot/src/legacy/types/reply.ts diff --git a/src/types/templateVariables.ts b/apps/bot/src/legacy/types/templateVariables.ts similarity index 100% rename from src/types/templateVariables.ts rename to apps/bot/src/legacy/types/templateVariables.ts diff --git a/src/utils/templateVariables.ts b/apps/bot/src/legacy/utils/templateVariables.ts similarity index 100% rename from src/utils/templateVariables.ts rename to apps/bot/src/legacy/utils/templateVariables.ts diff --git a/src/validators/logs.ts b/apps/bot/src/legacy/validators/logs.ts similarity index 100% rename from src/validators/logs.ts rename to apps/bot/src/legacy/validators/logs.ts diff --git a/src/validators/memberMessages.ts b/apps/bot/src/legacy/validators/memberMessages.ts similarity index 100% rename from src/validators/memberMessages.ts rename to apps/bot/src/legacy/validators/memberMessages.ts diff --git a/src/validators/presence.ts b/apps/bot/src/legacy/validators/presence.ts similarity index 100% rename from src/validators/presence.ts rename to apps/bot/src/legacy/validators/presence.ts diff --git a/apps/bot/src/manager/BotManager.ts b/apps/bot/src/manager/BotManager.ts new file mode 100644 index 0000000..9ecc1fe --- /dev/null +++ b/apps/bot/src/manager/BotManager.ts @@ -0,0 +1,233 @@ +import { Client, GatewayIntentBits } from "discord.js"; +import { decryptToken } from "@saas/shared"; +import type { Pool } from "pg"; + +import { + getBotCredentialsById, + insertRuntimeEvent, + listBotsToRestore, + setBotStatusById, +} from "../db/repositories.js"; +import { + initializeLegacyBotRuntime, + type LegacyBotRuntime, +} from "../runtime/initializeLegacyBotRuntime.js"; + +interface ManagedBotRuntime { + client: Client; + legacyRuntime: LegacyBotRuntime; + tenantId: string; + discordBotId: string; + startedAt: Date; +} + +interface RunningBotInfo { + botId: string; + tenantId: string; + discordBotId: string; + startedAt: string; +} + +export class BotManager { + private readonly runningBots = new Map(); + private readonly operationLocks = new Map>(); + + public constructor( + private readonly pool: Pool, + private readonly tokenEncryptionKey: Buffer, + ) {} + + public getRunningCount(): number { + return this.runningBots.size; + } + + public listRunningBots(): RunningBotInfo[] { + return Array.from(this.runningBots.entries()).map(([botId, runtime]) => ({ + botId, + tenantId: runtime.tenantId, + discordBotId: runtime.discordBotId, + startedAt: runtime.startedAt.toISOString(), + })); + } + + public async loadAndStartPersistedBots(): Promise { + const persistedBots = await listBotsToRestore(this.pool); + + for (const bot of persistedBots) { + await this.startBot(bot.id, bot.tenantId).catch(async (error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown startup error"; + + await setBotStatusById(this.pool, bot.id, "error", message); + await insertRuntimeEvent(this.pool, { + tenantId: bot.tenantId, + botId: bot.id, + level: "error", + message: "Bot failed to restore at startup", + metadata: { + error: message, + }, + }); + }); + } + } + + public async startBot(botId: string, tenantId?: string): Promise { + await this.withLock(botId, async () => { + if (this.runningBots.has(botId)) { + return; + } + + const bot = await getBotCredentialsById(this.pool, botId); + if (!bot) { + throw new Error("BOT_NOT_FOUND"); + } + + if (tenantId && bot.tenantId !== tenantId) { + throw new Error("TENANT_SCOPE_VIOLATION"); + } + + await setBotStatusById(this.pool, botId, "starting", null); + + const token = decryptToken( + { + ciphertext: bot.tokenCiphertext, + iv: bot.tokenIv, + tag: bot.tokenTag, + }, + this.tokenEncryptionKey, + ); + + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + + const legacyRuntime = await initializeLegacyBotRuntime({ + client, + pool: this.pool, + tenantId: bot.tenantId, + ownerUserId: bot.ownerUserId, + botToken: token, + botClientId: bot.discordBotId, + }); + + this.runningBots.set(botId, { + client, + legacyRuntime, + tenantId: bot.tenantId, + discordBotId: bot.discordBotId, + startedAt: new Date(), + }); + + client.once("ready", async () => { + await setBotStatusById(this.pool, botId, "running", null); + await insertRuntimeEvent(this.pool, { + tenantId: bot.tenantId, + botId, + level: "info", + message: "Bot client connected to Discord Gateway", + metadata: { + discordUserTag: client.user?.tag ?? null, + }, + }); + }); + + client.on("error", async (error) => { + await setBotStatusById(this.pool, botId, "error", error.message); + await insertRuntimeEvent(this.pool, { + tenantId: bot.tenantId, + botId, + level: "error", + message: "Discord client runtime error", + metadata: { + error: error.message, + }, + }); + }); + + try { + await client.login(token); + } catch (error) { + await legacyRuntime.shutdown().catch(() => undefined); + client.destroy(); + this.runningBots.delete(botId); + + const message = error instanceof Error ? error.message : "Unknown login error"; + await setBotStatusById(this.pool, botId, "error", message); + await insertRuntimeEvent(this.pool, { + tenantId: bot.tenantId, + botId, + level: "error", + message: "Bot failed to authenticate with Discord", + metadata: { + error: message, + }, + }); + + throw error; + } + }); + } + + public async stopBot(botId: string, tenantId?: string): Promise { + await this.withLock(botId, async () => { + const runtime = this.runningBots.get(botId); + + if (runtime && tenantId && runtime.tenantId !== tenantId) { + throw new Error("TENANT_SCOPE_VIOLATION"); + } + + if (!runtime) { + await setBotStatusById(this.pool, botId, "stopped", null); + return; + } + + await setBotStatusById(this.pool, botId, "stopping", null); + + await runtime.legacyRuntime.shutdown().catch(() => undefined); + runtime.client.destroy(); + this.runningBots.delete(botId); + + await setBotStatusById(this.pool, botId, "stopped", null); + await insertRuntimeEvent(this.pool, { + tenantId: runtime.tenantId, + botId, + level: "info", + message: "Bot stopped by control action", + }); + }); + } + + public async restartBot(botId: string, tenantId?: string): Promise { + await this.stopBot(botId, tenantId); + await this.startBot(botId, tenantId); + } + + public async shutdown(): Promise { + const botIds = [...this.runningBots.keys()]; + + for (const botId of botIds) { + await this.stopBot(botId).catch(() => undefined); + } + } + + private async withLock(botId: string, operation: () => Promise): Promise { + const previous = this.operationLocks.get(botId) ?? Promise.resolve(); + + const next = previous + .catch(() => undefined) + .then(operation) + .finally(() => { + if (this.operationLocks.get(botId) === next) { + this.operationLocks.delete(botId); + } + }); + + this.operationLocks.set(botId, next); + return next; + } +} diff --git a/apps/bot/src/queue/worker.ts b/apps/bot/src/queue/worker.ts new file mode 100644 index 0000000..d1a3508 --- /dev/null +++ b/apps/bot/src/queue/worker.ts @@ -0,0 +1,36 @@ +import type { Redis } from "ioredis"; +import { Worker } from "bullmq"; + +import { + BOT_CONTROL_QUEUE, + BOT_CONTROL_QUEUE_PREFIX, + type BotControlJob, +} from "@saas/shared"; + +import { BotManager } from "../manager/BotManager.js"; + +export const createBotControlWorker = (redis: Redis, manager: BotManager): Worker => { + return new Worker( + BOT_CONTROL_QUEUE, + async (job) => { + switch (job.data.action) { + case "start": + await manager.startBot(job.data.botId, job.data.tenantId); + break; + case "stop": + await manager.stopBot(job.data.botId, job.data.tenantId); + break; + case "restart": + await manager.restartBot(job.data.botId, job.data.tenantId); + break; + default: + throw new Error(`Unknown control action: ${job.data.action}`); + } + }, + { + connection: redis, + prefix: BOT_CONTROL_QUEUE_PREFIX, + concurrency: 10, + }, + ); +}; diff --git a/apps/bot/src/runtime/initializeLegacyBotRuntime.ts b/apps/bot/src/runtime/initializeLegacyBotRuntime.ts new file mode 100644 index 0000000..f12a281 --- /dev/null +++ b/apps/bot/src/runtime/initializeLegacyBotRuntime.ts @@ -0,0 +1,152 @@ +import type { Client } from "discord.js"; +import type { Pool } from "pg"; + +import type { AppFeatureServices } from "../legacy/app/container.js"; +import { createCommandList } from "../legacy/commands/index.js"; +import { env } from "../legacy/config/env.js"; +import { CommandRegistry } from "../legacy/core/commands/registry.js"; +import { LocalCommandDispatchPort } from "../legacy/core/execution/dispatch.js"; +import { + MemoryCooldownStore, +} from "../legacy/core/execution/cooldownStore.js"; +import { + MemoryGlobalRateLimitStore, +} from "../legacy/core/execution/globalRateLimitStore.js"; +import { CommandExecutor } from "../legacy/core/execution/CommandExecutor.js"; +import { createScopedLogger } from "../legacy/core/logging/logger.js"; +import { + LocalLeaderCoordinator, + PostgresLeaderCoordinator, +} from "../legacy/core/runtime/leaderCoordinator.js"; +import { DatabaseLifecycle } from "../legacy/database/dbLifecycle.js"; +import { registerEvents } from "../legacy/events/index.js"; +import { createPrefixHandler } from "../legacy/handlers/prefixHandler.js"; +import { createSlashHandler } from "../legacy/handlers/slashHandler.js"; +import { I18nService } from "../legacy/i18n/index.js"; +import { + LogEventService, +} from "../legacy/modules/logs/index.js"; +import { + MemberMessageService, +} from "../legacy/modules/memberMessages/index.js"; +import { + PresenceService, +} from "../legacy/modules/presence/index.js"; +import { TenantLogEventStore } from "./stores/TenantLogEventStore.js"; +import { TenantMemberMessageStore } from "./stores/TenantMemberMessageStore.js"; +import { TenantPresenceStore } from "./stores/TenantPresenceStore.js"; + +const log = createScopedLogger("legacy-runtime"); + +export interface InitializeLegacyBotRuntimeInput { + client: Client; + pool: Pool; + tenantId: string; + ownerUserId: string; + botToken: string; + botClientId: string; +} + +export interface LegacyBotRuntime { + shutdown: () => Promise; +} + +export const initializeLegacyBotRuntime = async ( + input: InitializeLegacyBotRuntimeInput, +): Promise => { + const presenceStore = new TenantPresenceStore(input.pool, input.tenantId, input.ownerUserId); + const memberMessageStore = new TenantMemberMessageStore(input.pool, input.tenantId, input.ownerUserId); + const logEventStore = new TenantLogEventStore(input.pool, input.tenantId, input.ownerUserId); + + const dbLifecycle = new DatabaseLifecycle( + [ + { name: "presenceStore", init: () => presenceStore.init() }, + { name: "memberMessageStore", init: () => memberMessageStore.init() }, + { name: "logEventStore", init: () => logEventStore.init() }, + ], + async () => undefined, + ); + + await dbLifecycle.init(); + + const services: AppFeatureServices = { + presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL), + memberMessageService: new MemberMessageService(memberMessageStore), + logEventService: new LogEventService(logEventStore), + }; + + const executor = new CommandExecutor({ + cooldownStore: new MemoryCooldownStore(), + globalRateLimitStore: new MemoryGlobalRateLimitStore(), + globalRateLimitPolicy: { + limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS, + windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS, + }, + rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN, + logger: createScopedLogger("legacy-command-executor"), + }); + + const dispatcher = new LocalCommandDispatchPort(executor); + const i18n = new I18nService(env.DEFAULT_LANG); + const registry = new CommandRegistry(createCommandList(services, i18n), i18n); + + const onPrefixMessage = createPrefixHandler({ + registry, + i18n, + dispatcher, + prefix: env.PREFIX, + defaultLang: env.DEFAULT_LANG, + }); + + const onSlashInteraction = createSlashHandler({ + registry, + i18n, + dispatcher, + prefix: env.PREFIX, + defaultLang: env.DEFAULT_LANG, + }); + + const leaderCoordinator = env.ENABLE_LEADER_ELECTION + ? new PostgresLeaderCoordinator(input.pool, "discord-saas-platform") + : new LocalLeaderCoordinator(); + + registerEvents( + input.client, + i18n, + { onPrefixMessage, onSlashInteraction }, + registry, + services, + leaderCoordinator, + { + token: input.botToken, + clientId: input.botClientId, + }, + ); + + log.info( + { + tenantId: input.tenantId, + botClientId: input.botClientId, + prefix: env.PREFIX, + defaultLang: env.DEFAULT_LANG, + autoDeploySlash: env.AUTO_DEPLOY_SLASH, + }, + "legacy runtime initialized", + ); + + return { + shutdown: async () => { + await services.logEventService.shutdown().catch((error) => { + log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "logs service shutdown failed"); + }); + + await services.presenceService.shutdown().catch((error) => { + log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "presence service shutdown failed"); + }); + + await dbLifecycle.shutdown().catch((error) => { + log.error({ err: error, tenantId: input.tenantId, botClientId: input.botClientId }, "runtime db lifecycle shutdown failed"); + }); + }, + }; +}; diff --git a/apps/bot/src/runtime/stores/TenantLogEventStore.ts b/apps/bot/src/runtime/stores/TenantLogEventStore.ts new file mode 100644 index 0000000..68246c0 --- /dev/null +++ b/apps/bot/src/runtime/stores/TenantLogEventStore.ts @@ -0,0 +1,159 @@ +import type { Pool } from "pg"; + +import type { + LogEventConfig, + LogEventKey, + LogEventRepositoryEntry, + LogEventRow, +} from "../../legacy/types/logs.js"; +import type { LogEventRepository } from "../../legacy/modules/logs/index.js"; + +const logEventSchemaProbeSql = ` +SELECT + bot_id, + tenant_id, + owner_user_id, + guild_id, + event_key, + enabled, + channel_id, + updated_at +FROM bot_log_event_configs +LIMIT 0; +`; + +export class TenantLogEventStore implements LogEventRepository { + public constructor( + private readonly pool: Pool, + private readonly tenantId: string, + private readonly ownerUserId: string, + ) {} + + public async init(): Promise { + try { + await this.pool.query(logEventSchemaProbeSql); + } catch (error) { + throw new Error( + "[db:init] missing or incompatible table \"bot_log_event_configs\". Run migrations with \"npm run migrate\".", + { cause: error }, + ); + } + } + + public async listByBotGuild(botId: string, guildId: string): Promise { + const result = await this.pool.query( + ` + SELECT event_key, enabled, channel_id + FROM bot_log_event_configs + WHERE bot_id = $1 AND tenant_id = $2 AND guild_id = $3 + ORDER BY event_key ASC + `, + [botId, this.tenantId, guildId], + ); + + return result.rows; + } + + public async upsertByBotGuildEvent( + botId: string, + guildId: string, + eventKey: LogEventKey, + config: LogEventConfig, + ): Promise { + await this.pool.query( + ` + INSERT INTO bot_log_event_configs ( + bot_id, + tenant_id, + owner_user_id, + guild_id, + event_key, + enabled, + channel_id, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (bot_id, guild_id, event_key) + DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + owner_user_id = EXCLUDED.owner_user_id, + enabled = EXCLUDED.enabled, + channel_id = EXCLUDED.channel_id, + updated_at = NOW() + `, + [ + botId, + this.tenantId, + this.ownerUserId, + guildId, + eventKey, + config.enabled, + config.channelId, + ], + ); + } + + public async upsertManyByBotGuildEvents( + botId: string, + guildId: string, + entries: readonly LogEventRepositoryEntry[], + ): Promise { + if (entries.length === 0) { + return; + } + + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + + for (const entry of entries) { + await client.query( + ` + INSERT INTO bot_log_event_configs ( + bot_id, + tenant_id, + owner_user_id, + guild_id, + event_key, + enabled, + channel_id, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (bot_id, guild_id, event_key) + DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + owner_user_id = EXCLUDED.owner_user_id, + enabled = EXCLUDED.enabled, + channel_id = EXCLUDED.channel_id, + updated_at = NOW() + `, + [ + botId, + this.tenantId, + this.ownerUserId, + guildId, + entry.eventKey, + entry.config.enabled, + entry.config.channelId, + ], + ); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + } + + public async deleteByBotGuild(botId: string, guildId: string): Promise { + await this.pool.query( + "DELETE FROM bot_log_event_configs WHERE bot_id = $1 AND tenant_id = $2 AND guild_id = $3", + [botId, this.tenantId, guildId], + ); + } +} diff --git a/apps/bot/src/runtime/stores/TenantMemberMessageStore.ts b/apps/bot/src/runtime/stores/TenantMemberMessageStore.ts new file mode 100644 index 0000000..f621f98 --- /dev/null +++ b/apps/bot/src/runtime/stores/TenantMemberMessageStore.ts @@ -0,0 +1,150 @@ +import type { Pool } from "pg"; + +import type { + MemberMessageConfig, + MemberMessageKind, + MemberMessageRow, +} from "../../legacy/types/memberMessages.js"; +import { + createDefaultMemberMessageConfig, + isMemberMessageRenderTypeValue, + sanitizeMemberMessageRoleIds, +} from "../../legacy/validators/memberMessages.js"; +import type { MemberMessageRepository } from "../../legacy/modules/memberMessages/index.js"; + +const memberMessageSchemaProbeSql = ` +SELECT + bot_id, + tenant_id, + owner_user_id, + guild_id, + kind, + enabled, + channel_id, + message_type, + auto_role_ids, + updated_at +FROM bot_member_message_configs +LIMIT 0; +`; + +const parseRoleIds = (serialized: string | null): string[] => { + if (!serialized || serialized.trim().length === 0) { + return []; + } + + try { + const parsed = JSON.parse(serialized) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + const roleIds = parsed.filter((value): value is string => typeof value === "string"); + return sanitizeMemberMessageRoleIds(roleIds); + } catch { + return []; + } +}; + +const toConfig = (row: MemberMessageRow): MemberMessageConfig => { + const fallback = createDefaultMemberMessageConfig(); + + return { + enabled: row.enabled, + channelId: row.channel_id, + messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType, + autoRoleIds: parseRoleIds(row.auto_role_ids), + }; +}; + +export class TenantMemberMessageStore implements MemberMessageRepository { + public constructor( + private readonly pool: Pool, + private readonly tenantId: string, + private readonly ownerUserId: string, + ) {} + + public async init(): Promise { + try { + await this.pool.query(memberMessageSchemaProbeSql); + } catch (error) { + throw new Error( + "[db:init] missing or incompatible table \"bot_member_message_configs\". Run migrations with \"npm run migrate\".", + { cause: error }, + ); + } + } + + public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise { + const result = await this.pool.query( + ` + SELECT enabled, channel_id, message_type, auto_role_ids + FROM bot_member_message_configs + WHERE bot_id = $1 AND tenant_id = $2 AND guild_id = $3 AND kind = $4 + LIMIT 1 + `, + [botId, this.tenantId, guildId, kind], + ); + + const row = result.rows[0]; + if (!row) { + return createDefaultMemberMessageConfig(); + } + + return toConfig(row); + } + + public async upsertByBotGuildKind( + botId: string, + guildId: string, + kind: MemberMessageKind, + config: MemberMessageConfig, + ): Promise { + const autoRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds); + + await this.pool.query( + ` + INSERT INTO bot_member_message_configs ( + bot_id, + tenant_id, + owner_user_id, + guild_id, + kind, + enabled, + channel_id, + message_type, + auto_role_ids, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) + ON CONFLICT (bot_id, guild_id, kind) + DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + owner_user_id = EXCLUDED.owner_user_id, + enabled = EXCLUDED.enabled, + channel_id = EXCLUDED.channel_id, + message_type = EXCLUDED.message_type, + auto_role_ids = EXCLUDED.auto_role_ids, + updated_at = NOW() + `, + [ + botId, + this.tenantId, + this.ownerUserId, + guildId, + kind, + config.enabled, + config.channelId, + config.messageType, + JSON.stringify(autoRoleIds), + ], + ); + } + + public async deleteByBotGuild(botId: string, guildId: string): Promise { + await this.pool.query( + "DELETE FROM bot_member_message_configs WHERE bot_id = $1 AND tenant_id = $2 AND guild_id = $3", + [botId, this.tenantId, guildId], + ); + } +} diff --git a/apps/bot/src/runtime/stores/TenantPresenceStore.ts b/apps/bot/src/runtime/stores/TenantPresenceStore.ts new file mode 100644 index 0000000..76e5d45 --- /dev/null +++ b/apps/bot/src/runtime/stores/TenantPresenceStore.ts @@ -0,0 +1,157 @@ +import type { Pool } from "pg"; + +import type { + PresenceActivityTypeValue, + PresenceRow, + PresenceState, + PresenceStatusValue, +} from "../../legacy/types/presence.js"; +import { + DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS, + createDefaultPresenceState, + isPresenceActivityTypeValue, + isPresenceStatusValue, + sanitizeActivityText, + sanitizeActivityTexts, + sanitizePresenceRotationIntervalSeconds, +} from "../../legacy/validators/presence.js"; +import type { PresenceRepository } from "../../legacy/modules/presence/index.js"; + +const presenceSchemaProbeSql = ` +SELECT + bot_id, + tenant_id, + owner_user_id, + status, + activity_type, + activity_text, + activity_texts, + rotation_interval_seconds, + updated_at +FROM bot_presence_states +LIMIT 0; +`; + +const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => { + if (typeof rawTexts === "string" && rawTexts.trim().length > 0) { + try { + const parsed = JSON.parse(rawTexts) as unknown; + if (Array.isArray(parsed)) { + const stringValues = parsed + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (stringValues.length > 0) { + return sanitizeActivityTexts(stringValues); + } + } + } catch { + // Fall back to legacy single text when malformed JSON is encountered. + } + } + + return sanitizeActivityTexts([fallbackText]); +}; + +const toPresenceState = (row: PresenceRow): PresenceState | null => { + if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) { + return null; + } + + const texts = parseStoredTexts(row.activity_texts, row.activity_text); + const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds( + row.rotation_interval_seconds ?? DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS, + ); + + return { + status: row.status as PresenceStatusValue, + activity: { + type: row.activity_type as PresenceActivityTypeValue, + text: texts[0] ?? sanitizeActivityText(row.activity_text), + texts, + rotationIntervalSeconds, + }, + }; +}; + +export class TenantPresenceStore implements PresenceRepository { + public constructor( + private readonly pool: Pool, + private readonly tenantId: string, + private readonly ownerUserId: string, + ) {} + + public async init(): Promise { + try { + await this.pool.query(presenceSchemaProbeSql); + } catch (error) { + throw new Error( + "[db:init] missing or incompatible table \"bot_presence_states\". Run migrations with \"npm run migrate\".", + { cause: error }, + ); + } + } + + public async getByBotId(botId: string): Promise { + const result = await this.pool.query( + ` + SELECT status, activity_type, activity_text, activity_texts, rotation_interval_seconds + FROM bot_presence_states + WHERE bot_id = $1 AND tenant_id = $2 + LIMIT 1 + `, + [botId, this.tenantId], + ); + + const row = result.rows[0]; + if (!row) { + return createDefaultPresenceState(); + } + + return toPresenceState(row) ?? createDefaultPresenceState(); + } + + public async upsertByBotId(botId: string, state: PresenceState): Promise { + const activityTexts = sanitizeActivityTexts(state.activity.texts); + const primaryText = activityTexts[0] ?? sanitizeActivityText(state.activity.text); + const rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds); + + await this.pool.query( + ` + INSERT INTO bot_presence_states ( + bot_id, + tenant_id, + owner_user_id, + status, + activity_type, + activity_text, + activity_texts, + rotation_interval_seconds, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (bot_id) + DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + owner_user_id = EXCLUDED.owner_user_id, + status = EXCLUDED.status, + activity_type = EXCLUDED.activity_type, + activity_text = EXCLUDED.activity_text, + activity_texts = EXCLUDED.activity_texts, + rotation_interval_seconds = EXCLUDED.rotation_interval_seconds, + updated_at = NOW() + `, + [ + botId, + this.tenantId, + this.ownerUserId, + state.status, + state.activity.type, + primaryText, + JSON.stringify(activityTexts), + rotationIntervalSeconds, + ], + ); + } +} diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..b0f4e56 --- /dev/null +++ b/apps/bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..cec4a4b --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS deps + +WORKDIR /workspace + +COPY package.json package-lock.json tsconfig.base.json ./ +COPY apps/api/package.json ./apps/api/package.json +COPY apps/bot/package.json ./apps/bot/package.json +COPY apps/web/package.json ./apps/web/package.json +COPY packages/shared/package.json ./packages/shared/package.json + +RUN npm ci + +FROM node:20-alpine AS builder + +WORKDIR /workspace + +ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:4000 +ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} + +COPY --from=deps /workspace/node_modules ./node_modules +COPY package.json package-lock.json tsconfig.base.json ./ +COPY apps/web ./apps/web +COPY apps/api/package.json ./apps/api/package.json +COPY apps/bot/package.json ./apps/bot/package.json +COPY packages/shared/package.json ./packages/shared/package.json + +RUN npm run build -w @saas/web + +FROM node:20-alpine AS runner + +WORKDIR /workspace +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=deps /workspace/node_modules ./node_modules +COPY package.json package-lock.json ./ +COPY apps/web/package.json ./apps/web/package.json +COPY --from=builder /workspace/apps/web ./apps/web + +CMD ["npm", "run", "start", "-w", "@saas/web"] diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..3bfbc08 --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -0,0 +1,15 @@ +import { DashboardClient } from "../../components/dashboard-client"; + +const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000"; + +export const dynamic = "force-dynamic"; + +export default function DashboardPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..5d1bce8 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,280 @@ +:root { + --sand-50: #f7f2e8; + --sand-100: #efe3ce; + --ink-900: #1b1815; + --ink-700: #3f3933; + --sun-500: #ec6f08; + --sun-600: #cb5600; + --teal-600: #0f766e; + --teal-700: #0b5f58; + --danger-600: #b91c1c; + --card-shadow: 0 20px 60px rgba(33, 25, 17, 0.15); + --radius-lg: 20px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + font-family: var(--font-heading), sans-serif; + color: var(--ink-900); + background: + radial-gradient(circle at 15% 20%, rgba(15, 118, 110, 0.18), transparent 35%), + radial-gradient(circle at 80% 0%, rgba(236, 111, 8, 0.22), transparent 40%), + linear-gradient(140deg, var(--sand-50), #f8f7f4 55%, #fff3e3); +} + +.page-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem 1rem; +} + +.panel { + width: min(1100px, 100%); + background: linear-gradient(170deg, rgba(255, 255, 255, 0.95), rgba(255, 247, 237, 0.86)); + border: 1px solid rgba(203, 86, 0, 0.16); + border-radius: var(--radius-lg); + box-shadow: var(--card-shadow); + backdrop-filter: blur(6px); +} + +.panel-login { + width: min(560px, 100%); + padding: 2rem; +} + +.panel-dashboard { + padding: 1.75rem; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--teal-700); + font-size: 0.75rem; + font-weight: 700; +} + +h1, +h2 { + margin: 0 0 0.5rem; +} + +p { + margin: 0; + color: var(--ink-700); +} + +.stack { + display: grid; + gap: 1.25rem; +} + +.stack-tight { + display: grid; + gap: 0.75rem; +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.button-primary, +.button-ghost, +.actions button { + border-radius: 12px; + border: 0; + padding: 0.7rem 1rem; + font-family: var(--font-heading), sans-serif; + font-weight: 700; + cursor: pointer; + transition: transform 120ms ease, background-color 120ms ease; +} + +.button-primary { + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(140deg, var(--sun-500), var(--sun-600)); + color: white; + text-decoration: none; +} + +.button-primary:hover { + transform: translateY(-1px); +} + +.button-ghost, +.actions button { + background: rgba(15, 118, 110, 0.08); + color: var(--teal-700); +} + +.button-ghost:hover, +.actions button:hover { + background: rgba(15, 118, 110, 0.14); +} + +.card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.card { + border-radius: 14px; + border: 1px solid rgba(15, 118, 110, 0.18); + background: rgba(255, 255, 255, 0.88); + padding: 1rem; + display: grid; + gap: 0.75rem; +} + +label { + display: grid; + gap: 0.3rem; + font-weight: 600; + color: var(--ink-700); +} + +input { + width: 100%; + border: 1px solid rgba(62, 57, 51, 0.2); + border-radius: 10px; + padding: 0.65rem 0.75rem; + font: inherit; + background: #fff; +} + +input:focus { + outline: 2px solid rgba(236, 111, 8, 0.32); + border-color: var(--sun-600); +} + +.row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.bot-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.75rem; +} + +.bot-item { + border: 1px solid rgba(62, 57, 51, 0.14); + border-radius: 12px; + padding: 0.8rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.bot-main { + display: grid; + gap: 0.25rem; +} + +.bot-name { + color: var(--ink-900); + font-size: 1.1rem; + font-weight: 700; +} + +.status { + display: inline-flex; + padding: 0.2rem 0.55rem; + border-radius: 999px; + width: fit-content; + font-size: 0.8rem; + font-weight: 700; +} + +.status-running { + background: rgba(15, 118, 110, 0.16); + color: var(--teal-700); +} + +.status-starting, +.status-stopping { + background: rgba(236, 111, 8, 0.16); + color: var(--sun-600); +} + +.status-stopped { + background: rgba(63, 57, 51, 0.12); + color: var(--ink-700); +} + +.status-error { + background: rgba(185, 28, 28, 0.13); + color: var(--danger-600); +} + +.actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.error-banner, +.error-inline { + color: var(--danger-600); + font-weight: 600; +} + +.muted { + color: rgba(63, 57, 51, 0.85); +} + +.mono { + font-family: var(--font-mono), monospace; +} + +.reveal-up { + animation: revealUp 500ms ease-out both; +} + +@keyframes revealUp { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (min-width: 920px) { + .card-grid { + grid-template-columns: 0.95fr 1.35fr; + } + + .panel-dashboard { + padding: 2rem; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..85ddcb1 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; + +import "./globals.css"; + +const headingFont = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-heading", + weight: ["500", "700"], +}); + +const monoFont = IBM_Plex_Mono({ + subsets: ["latin"], + variable: "--font-mono", + weight: ["400", "500"], +}); + +export const metadata: Metadata = { + title: "Discord Bot SaaS", + description: "Multi-tenant Discord bot management platform", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..94d0099 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,19 @@ +const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000"; + +export default function LoginPage() { + return ( +
+
+

Discord Bot SaaS Platform

+

Connecte ton compte Discord

+

+ Authentifie-toi via OAuth2 pour accéder à ton espace multi-tenant et piloter les bots de ton + organisation. +

+ + Continuer avec Discord + +
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..9ef1235 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/dashboard"); +} diff --git a/apps/web/components/dashboard-client.tsx b/apps/web/components/dashboard-client.tsx new file mode 100644 index 0000000..e1ecf92 --- /dev/null +++ b/apps/web/components/dashboard-client.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { type FormEvent, useCallback, useEffect, useState } from "react"; + +type User = { + id: string; + tenantId: string; + username: string; + avatarUrl: string | null; + role: "owner" | "member"; +}; + +type BotStatus = "stopped" | "starting" | "running" | "stopping" | "error"; + +type Bot = { + id: string; + tenantId: string; + discordBotId: string; + displayName: string; + status: BotStatus; + lastError: string | null; + createdAt: string; + updatedAt: string; +}; + +type DashboardClientProps = { + apiBaseUrl: string; +}; + +const statusLabel: Record = { + stopped: "Arrete", + starting: "Demarrage", + running: "En ligne", + stopping: "Arret", + error: "Erreur", +}; + +export function DashboardClient({ apiBaseUrl }: DashboardClientProps) { + const [user, setUser] = useState(null); + const [bots, setBots] = useState([]); + const [token, setToken] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const refreshData = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const meResponse = await fetch(`${apiBaseUrl}/api/me`, { + credentials: "include", + }); + + if (meResponse.status === 401) { + setUser(null); + setBots([]); + setLoading(false); + return; + } + + if (!meResponse.ok) { + throw new Error("Impossible de recuperer la session utilisateur"); + } + + const meJson = await meResponse.json(); + setUser(meJson.user as User); + + const botsResponse = await fetch(`${apiBaseUrl}/api/bots`, { + credentials: "include", + }); + + if (!botsResponse.ok) { + throw new Error("Impossible de recuperer la liste des bots"); + } + + const botsJson = await botsResponse.json(); + setBots((botsJson.bots ?? []) as Bot[]); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Erreur inattendue"; + setError(message); + } finally { + setLoading(false); + } + }, [apiBaseUrl]); + + useEffect(() => { + void refreshData(); + }, [refreshData]); + + const handleLogout = async () => { + await fetch(`${apiBaseUrl}/auth/logout`, { + method: "POST", + credentials: "include", + }); + + setUser(null); + setBots([]); + }; + + const handleAddBot = async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + const response = await fetch(`${apiBaseUrl}/api/bots`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token, + displayName: displayName.trim().length > 0 ? displayName : undefined, + }), + }); + + if (!response.ok) { + const errorJson = await response.json().catch(() => ({})); + throw new Error(errorJson.error ?? "Ajout du bot impossible"); + } + + setToken(""); + setDisplayName(""); + await refreshData(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Erreur inattendue"; + setError(message); + } finally { + setSubmitting(false); + } + }; + + const triggerAction = async (botId: string, action: "start" | "stop" | "restart") => { + setError(null); + + try { + const response = await fetch(`${apiBaseUrl}/api/bots/${botId}/${action}`, { + method: "POST", + credentials: "include", + }); + + if (!response.ok) { + const errorJson = await response.json().catch(() => ({})); + throw new Error(errorJson.error ?? `Action ${action} impossible`); + } + + await refreshData(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Erreur inattendue"; + setError(message); + } + }; + + if (loading) { + return

Chargement du dashboard...

; + } + + if (!user) { + return ( +
+

Session requise

+

Connexion necessaire

+

Ton dashboard est protege. Connecte-toi via Discord pour administrer tes bots.

+ + Se connecter + +
+ ); + } + + return ( +
+
+
+

Tenant {user.tenantId}

+

{user.username}

+

Role: {user.role}

+
+ +
+ + {error ?

{error}

: null} + +
+
+

Ajouter un bot

+

Le token est verifie cote API puis chiffre avant stockage en base.

+
+ + + +
+
+ +
+
+

Mes bots

+ +
+ + {bots.length === 0 ? ( +

Aucun bot pour ce tenant.

+ ) : ( +
    + {bots.map((bot) => ( +
  • +
    +

    {bot.displayName}

    +

    Discord ID: {bot.discordBotId}

    +

    {statusLabel[bot.status]}

    + {bot.lastError ?

    Derniere erreur: {bot.lastError}

    : null} +
    +
    + + + +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..01761fd --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: "standalone", +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..cee7fe7 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "@saas/web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "next": "15.1.2", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2" + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..8b4b9d5 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "incremental": true, + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "noEmit": true, + "isolatedModules": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/database/migrations/0004_saas_multitenant.sql b/database/migrations/0004_saas_multitenant.sql new file mode 100644 index 0000000..f2bcb3a --- /dev/null +++ b/database/migrations/0004_saas_multitenant.sql @@ -0,0 +1,220 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + discord_user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + avatar_url TEXT, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_tenants_owner_user' + ) THEN + ALTER TABLE tenants + ADD CONSTRAINT fk_tenants_owner_user + FOREIGN KEY (owner_user_id) + REFERENCES users(id) + ON DELETE SET NULL; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS bots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + discord_bot_id TEXT NOT NULL, + display_name TEXT NOT NULL, + token_ciphertext TEXT NOT NULL, + token_iv TEXT NOT NULL, + token_tag TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'error')), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, discord_bot_id), + UNIQUE (discord_bot_id) +); + +CREATE TABLE IF NOT EXISTS bot_runtime_events ( + id BIGSERIAL PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE, + level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), + message TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users (tenant_id); +CREATE INDEX IF NOT EXISTS idx_bots_tenant_id ON bots (tenant_id); +CREATE INDEX IF NOT EXISTS idx_bot_runtime_events_tenant_created ON bot_runtime_events (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_bot_runtime_events_bot_created ON bot_runtime_events (bot_id, created_at DESC); + +CREATE OR REPLACE FUNCTION set_updated_at_timestamp() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_timestamp(); + +DROP TRIGGER IF EXISTS trg_bots_updated_at ON bots; +CREATE TRIGGER trg_bots_updated_at + BEFORE UPDATE ON bots + FOR EACH ROW + EXECUTE FUNCTION set_updated_at_timestamp(); + +ALTER TABLE bot_presence_states + ADD COLUMN IF NOT EXISTS tenant_id UUID, + ADD COLUMN IF NOT EXISTS owner_user_id UUID; + +ALTER TABLE bot_member_message_configs + ADD COLUMN IF NOT EXISTS tenant_id UUID, + ADD COLUMN IF NOT EXISTS owner_user_id UUID; + +ALTER TABLE bot_log_event_configs + ADD COLUMN IF NOT EXISTS tenant_id UUID, + ADD COLUMN IF NOT EXISTS owner_user_id UUID; + +UPDATE bot_presence_states p +SET tenant_id = b.tenant_id, + owner_user_id = b.owner_user_id +FROM bots b +WHERE p.bot_id = b.discord_bot_id + AND (p.tenant_id IS NULL OR p.owner_user_id IS NULL); + +UPDATE bot_member_message_configs m +SET tenant_id = b.tenant_id, + owner_user_id = b.owner_user_id +FROM bots b +WHERE m.bot_id = b.discord_bot_id + AND (m.tenant_id IS NULL OR m.owner_user_id IS NULL); + +UPDATE bot_log_event_configs l +SET tenant_id = b.tenant_id, + owner_user_id = b.owner_user_id +FROM bots b +WHERE l.bot_id = b.discord_bot_id + AND (l.tenant_id IS NULL OR l.owner_user_id IS NULL); + +DELETE FROM bot_presence_states p +WHERE p.tenant_id IS NULL OR p.owner_user_id IS NULL; + +DELETE FROM bot_member_message_configs m +WHERE m.tenant_id IS NULL OR m.owner_user_id IS NULL; + +DELETE FROM bot_log_event_configs l +WHERE l.tenant_id IS NULL OR l.owner_user_id IS NULL; + +ALTER TABLE bot_presence_states + ALTER COLUMN tenant_id SET NOT NULL, + ALTER COLUMN owner_user_id SET NOT NULL; + +ALTER TABLE bot_member_message_configs + ALTER COLUMN tenant_id SET NOT NULL, + ALTER COLUMN owner_user_id SET NOT NULL; + +ALTER TABLE bot_log_event_configs + ALTER COLUMN tenant_id SET NOT NULL, + ALTER COLUMN owner_user_id SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_presence_states_tenant' + ) THEN + ALTER TABLE bot_presence_states + ADD CONSTRAINT fk_bot_presence_states_tenant + FOREIGN KEY (tenant_id) + REFERENCES tenants(id) + ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_presence_states_owner_user' + ) THEN + ALTER TABLE bot_presence_states + ADD CONSTRAINT fk_bot_presence_states_owner_user + FOREIGN KEY (owner_user_id) + REFERENCES users(id) + ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_member_message_configs_tenant' + ) THEN + ALTER TABLE bot_member_message_configs + ADD CONSTRAINT fk_bot_member_message_configs_tenant + FOREIGN KEY (tenant_id) + REFERENCES tenants(id) + ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_member_message_configs_owner_user' + ) THEN + ALTER TABLE bot_member_message_configs + ADD CONSTRAINT fk_bot_member_message_configs_owner_user + FOREIGN KEY (owner_user_id) + REFERENCES users(id) + ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_log_event_configs_tenant' + ) THEN + ALTER TABLE bot_log_event_configs + ADD CONSTRAINT fk_bot_log_event_configs_tenant + FOREIGN KEY (tenant_id) + REFERENCES tenants(id) + ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_bot_log_event_configs_owner_user' + ) THEN + ALTER TABLE bot_log_event_configs + ADD CONSTRAINT fk_bot_log_event_configs_owner_user + FOREIGN KEY (owner_user_id) + REFERENCES users(id) + ON DELETE RESTRICT; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_bot_presence_states_tenant_id ON bot_presence_states (tenant_id); +CREATE INDEX IF NOT EXISTS idx_bot_member_message_configs_tenant_id ON bot_member_message_configs (tenant_id); +CREATE INDEX IF NOT EXISTS idx_bot_log_event_configs_tenant_id ON bot_log_event_configs (tenant_id); diff --git a/docker-compose.yml b/docker-compose.yml index 90d0275..7418102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,51 +4,24 @@ x-logging: &default-logging max-size: "10m" max-file: "3" -x-bot-common: &bot-common - build: - context: . - dockerfile: Dockerfile +x-app-common: &app-common restart: unless-stopped init: true - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy security_opt: - no-new-privileges:true cap_drop: - ALL stop_grace_period: 30s - networks: - - edge_net - - backend_net logging: *default-logging services: - bot_alpha: - <<: *bot-common - env_file: - - .env.bot-alpha.prod - environment: - STATE_BACKEND: redis - COMMAND_DISPATCH_MODE: local - - bot_beta: - <<: *bot-common - env_file: - - .env.bot-beta.prod - environment: - STATE_BACKEND: redis - COMMAND_DISPATCH_MODE: local - postgres: image: postgres:16-alpine restart: unless-stopped init: true environment: - POSTGRES_DB: discord_bots - POSTGRES_USER: discord_bot + POSTGRES_DB: ${POSTGRES_DB:-discord_saas} + POSTGRES_USER: ${POSTGRES_USER:-discord_saas} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 --auth-local=scram-sha-256 volumes: @@ -96,6 +69,81 @@ services: - backend_net logging: *default-logging + api: + <<: *app-common + build: + context: . + dockerfile: apps/api/Dockerfile + env_file: + - .env + environment: + NODE_ENV: production + PORT: 4000 + DATABASE_URL: postgresql://${POSTGRES_USER:-discord_saas}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-discord_saas} + DATABASE_SSL: "false" + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + WEB_URL: ${WEB_URL:-http://localhost:3000} + API_BASE_URL: ${API_BASE_URL:-http://localhost:4000} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "4000:4000" + networks: + - edge_net + - backend_net + + bot: + <<: *app-common + build: + context: . + dockerfile: apps/bot/Dockerfile + env_file: + - .env + environment: + NODE_ENV: production + PORT: 4100 + DATABASE_URL: postgresql://${POSTGRES_USER:-discord_saas}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-discord_saas} + DATABASE_SSL: "false" + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + api: + condition: service_started + ports: + - "4100:4100" + networks: + - edge_net + - backend_net + + web: + <<: *app-common + build: + context: . + dockerfile: apps/web/Dockerfile + args: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:4000} + env_file: + - .env + environment: + NODE_ENV: production + PORT: 3000 + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:4000} + API_BASE_URL: ${API_BASE_URL:-http://api:4000} + depends_on: + api: + condition: service_started + ports: + - "3000:3000" + networks: + - edge_net + - backend_net + volumes: postgres_data: redis_data: diff --git a/package-lock.json b/package-lock.json index 86d6c93..0ceee8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,76 @@ { - "name": "discordjs-framework-template", - "version": "1.0.0", + "name": "discord-bot-saas-platform", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "discordjs-framework-template", - "version": "1.0.0", - "dependencies": { - "@napi-rs/canvas": "^0.1.97", - "discord.js": "14.26.2", - "dotenv": "16.4.5", - "ioredis": "^5.10.1", - "pg": "^8.20.0", - "pino": "^10.3.1", - "zod": "3.23.8" - }, + "name": "discord-bot-saas-platform", + "version": "2.0.0", + "workspaces": [ + "apps/*", + "packages/*" + ], "devDependencies": { - "@types/node": "20.17.24", - "@types/pg": "^8.20.0", - "esbuild": "^0.25.12", - "tsx": "4.19.2", - "typescript": "5.8.2" + "@types/node": "^22.10.7", + "tsx": "^4.19.3", + "typescript": "^5.7.3" }, "engines": { - "node": ">=18.17.0" + "node": ">=20.0.0" + } + }, + "apps/api": { + "name": "@saas/api", + "version": "1.0.0", + "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" + } + }, + "apps/bot": { + "name": "@saas/bot", + "version": "1.0.0", + "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" + } + }, + "apps/web": { + "name": "@saas/web", + "version": "1.0.0", + "dependencies": { + "next": "15.1.2", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2" } }, "node_modules/@discordjs/builders": { @@ -167,10 +213,20 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -185,9 +241,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -202,9 +258,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -219,9 +275,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -236,9 +292,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -253,9 +309,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -270,9 +326,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -287,9 +343,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -304,9 +360,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -321,9 +377,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -338,9 +394,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -355,9 +411,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -372,9 +428,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -389,9 +445,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -406,9 +462,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -423,9 +479,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -440,9 +496,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -457,9 +513,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -474,9 +530,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -491,9 +547,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -508,9 +564,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -525,9 +581,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -542,9 +598,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -559,9 +615,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -576,9 +632,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -593,9 +649,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -609,16 +665,455 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/canvas": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", - "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.98.tgz", + "integrity": "sha512-WDg3lxYMqlrg49sDVUlrHVfIEPsd5AjYDRuGD6Fu82K5agJx0UnWA+l5qd53GNLRiMN2WhOw7FLR+Er5QB/0SA==", "license": "MIT", "workspaces": [ "e2e/*" @@ -631,23 +1126,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.97", - "@napi-rs/canvas-darwin-arm64": "0.1.97", - "@napi-rs/canvas-darwin-x64": "0.1.97", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", - "@napi-rs/canvas-linux-arm64-musl": "0.1.97", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", - "@napi-rs/canvas-linux-x64-gnu": "0.1.97", - "@napi-rs/canvas-linux-x64-musl": "0.1.97", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", - "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + "@napi-rs/canvas-android-arm64": "0.1.98", + "@napi-rs/canvas-darwin-arm64": "0.1.98", + "@napi-rs/canvas-darwin-x64": "0.1.98", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.98", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.98", + "@napi-rs/canvas-linux-arm64-musl": "0.1.98", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.98", + "@napi-rs/canvas-linux-x64-gnu": "0.1.98", + "@napi-rs/canvas-linux-x64-musl": "0.1.98", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.98", + "@napi-rs/canvas-win32-x64-msvc": "0.1.98" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", - "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.98.tgz", + "integrity": "sha512-O45Ifr0WZJUrSyg0QgB+67TiC0zYBRkBK+d43ZV4JtlwH3XttiVxLvlxEeULiH5y1MSELruspF0bjF6xXwJNPQ==", "cpu": [ "arm64" ], @@ -665,9 +1160,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", - "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.98.tgz", + "integrity": "sha512-1b/nQhw6Isdv14JokUqat+i5wrAYD+ce3egiotedBGRUjVxYSj4s2uQCh2bFsyX5/9A5iTKVGsWoQhFft+j7Lg==", "cpu": [ "arm64" ], @@ -685,9 +1180,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", - "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.98.tgz", + "integrity": "sha512-oefzfBM8mwnyYp6S+yNXwjCoLdkOalFG24mssHgvrJDS0FulOryyI35Q7GdJGmrzuL4oo1XW3ZTOcTBLdJ8Zkg==", "cpu": [ "x64" ], @@ -705,9 +1200,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", - "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.98.tgz", + "integrity": "sha512-NDH5QXGmf8wlo5yhijCNGVFiJk7an5GvHwb2LHyfLQWY/6/S48i5+YtY6FPqPVVCUckNGudYOfXEJnb3/FiJGQ==", "cpu": [ "arm" ], @@ -725,9 +1220,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", - "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.98.tgz", + "integrity": "sha512-KBLLM6tu1xs80LSAqdSLBKkgct0S23MCEf/aq8yxzg5imAceqp1ulKeELgWaYm27MgpUhm3Q7jmegX12FfphwA==", "cpu": [ "arm64" ], @@ -745,9 +1240,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", - "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.98.tgz", + "integrity": "sha512-mfMNhjN5zDcJafqQ6sHj4Tc3YMTRxP5UA3MHtp/ssytBR/k6XO0x+1IIPtscnUKwha+ql1++WjDCGEgqu8OfWQ==", "cpu": [ "arm64" ], @@ -765,9 +1260,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", - "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.98.tgz", + "integrity": "sha512-nfW8esrcaeuhrO3qGA5cwuyk4Ak6cn2eB0LtEYtqROIl+fz06CNGNCU0M95+Tspw5ZgfSbc98SaigT5r5B3LVQ==", "cpu": [ "riscv64" ], @@ -785,9 +1280,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", - "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.98.tgz", + "integrity": "sha512-318UT8j6Gro2bTjtutjQXHWp9SLTNw+WRS4wQ6XIRPAyzBGnGHg7x2ndD+oqkPrrSRIbYLA5WoBcCasaF7lSTQ==", "cpu": [ "x64" ], @@ -805,9 +1300,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", - "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.98.tgz", + "integrity": "sha512-0vZhI74UxnA4VqlW4UvM0dFRrjE1RLEe/OXSBjzytGIxV+yOG4exlrhGoIpAQaIpQQQXMCdb1EmbvPC1k9vEqQ==", "cpu": [ "x64" ], @@ -825,9 +1320,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", - "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.98.tgz", + "integrity": "sha512-oiC/IxgFEEVcZ7VH7JXXlmgsqRvmFb57PIQ4gQck35IKFZCNUvdNCcN3OeoLP7Hpf5160MWJf9jj/+E5V0bSvw==", "cpu": [ "arm64" ], @@ -845,9 +1340,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", - "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "version": "0.1.98", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.98.tgz", + "integrity": "sha512-ZqstKAJBSyZetU8udUvBQWPlGN9buawFvjuo9mgCAxzbOoJAgXX39ihec/nn42T5Vb6/qyn45eTimx5ND9kMEw==", "cpu": [ "x64" ], @@ -864,12 +1359,162 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@next/env": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.2.tgz", + "integrity": "sha512-Hm3jIGsoUl6RLB1vzY+dZeqb+/kWPZ+h34yiWxW0dV87l8Im/eMOwpOA+a0L78U0HM04syEjXuRlCozqpwuojQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.2.tgz", + "integrity": "sha512-b9TN7q+j5/7+rGLhFAVZiKJGIASuo8tWvInGfAd8wsULjB1uNGRCj1z1WZwwPWzVQbIKWFYqc+9L7W09qwt52w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.2.tgz", + "integrity": "sha512-caR62jNDUCU+qobStO6YJ05p9E+LR0EoXh1EEmyU69cYydsAy7drMcOlUlRtQihM6K6QfvNwJuLhsHcCzNpqtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.2.tgz", + "integrity": "sha512-fHHXBusURjBmN6VBUtu6/5s7cCeEkuGAb/ZZiGHBLVBXMBy4D5QpM8P33Or8JD1nlOjm/ZT9sEE5HouQ0F+hUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.2.tgz", + "integrity": "sha512-9CF1Pnivij7+M3G74lxr+e9h6o2YNIe7QtExWq1KUK4hsOLTBv6FJikEwCaC3NeYTflzrm69E5UfwEAbV2U9/g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.2.tgz", + "integrity": "sha512-tINV7WmcTUf4oM/eN3Yuu/f8jQ5C6AkueZPKeALs/qfdfX57eNv4Ij7rt0SA6iZ8+fMobVfcFVv664Op0caCCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.2.tgz", + "integrity": "sha512-jf2IseC4WRsGkzeUw/cK3wci9pxR53GlLAt30+y+B+2qAQxMw6WAC3QrANIKxkcoPU3JFh/10uFfmoMDF9JXKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.2.tgz", + "integrity": "sha512-wvg7MlfnaociP7k8lxLX4s2iBJm4BrNiNFhVUY+Yur5yhAJHfkS8qPPeDEUH8rQiY0PX3u/P7Q/wcg6Mv6GSAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.2.tgz", + "integrity": "sha512-D3cNA8NoT3aWISWmo7HF5Eyko/0OdOO+VagkoJuiTk7pyX3P/b+n8XA/MYvyR+xSVcbKn68B1rY9fgqjNISqzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@saas/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@saas/bot": { + "resolved": "apps/bot", + "link": true + }, + "node_modules/@saas/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@saas/web": { + "resolved": "apps/web", + "link": true + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -903,13 +1548,110 @@ "npm": ">=7.0.0" } }, - "node_modules/@types/node": { - "version": "20.17.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.24.tgz", - "integrity": "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==", + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" } }, "node_modules/@types/pg": { @@ -924,6 +1666,74 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -943,6 +1753,25 @@ "npm": ">=7.0.0" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -952,6 +1781,135 @@ "node": ">=8.0.0" } }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.74.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.74.1.tgz", + "integrity": "sha512-GfJEos2zoOGM9xqkB7VZouwwFuejKFqm667cBcmbBekJXKqqXWk4QYP3Uy2pzgUwCbg1cR7GgGmGczM7fnhWSA==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -961,6 +1919,136 @@ "node": ">=0.10.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -987,6 +2075,35 @@ "node": ">=0.10" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/discord-api-types": { "version": "0.38.45", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", @@ -1024,9 +2141,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -1035,10 +2152,69 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1049,40 +2225,167 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1098,6 +2401,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -1111,6 +2460,80 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -1135,6 +2558,31 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT", + "optional": true + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1159,18 +2607,251 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "license": "MIT" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-bytes.js": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.2.tgz", + "integrity": "sha512-nLJDV7peNy+0oHlmY2JZjzMfJ8Aj0/dd3jCwSZS8ZiO5nkQfcZRqDrRN3U5rJtqVTQneIOGZzb6LCNrk7trMCQ==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.1.2", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.2", + "@next/swc-darwin-x64": "15.1.2", + "@next/swc-linux-arm64-gnu": "15.1.2", + "@next/swc-linux-arm64-musl": "15.1.2", + "@next/swc-linux-x64-gnu": "15.1.2", + "@next/swc-linux-x64-musl": "15.1.2", + "@next/swc-win32-arm64-msvc": "15.1.2", + "@next/swc-win32-x64-msvc": "15.1.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1180,6 +2861,33 @@ "node": ">=14.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -1270,6 +2978,12 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -1307,6 +3021,34 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1362,12 +3104,87 @@ ], "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -1408,6 +3225,26 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -1417,6 +3254,212 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -1426,6 +3469,15 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1441,6 +3493,46 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -1453,6 +3545,15 @@ "node": ">=20" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -1466,13 +3567,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -1485,452 +3586,17 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "node": ">= 0.6" } }, "node_modules/typescript": { @@ -1957,11 +3623,51 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -1993,13 +3699,17 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/shared": { + "name": "@saas/shared", + "version": "1.0.0" } } } diff --git a/package.json b/package.json index 343aab4..2382c9d 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,30 @@ { - "name": "discordjs-framework-template", - "version": "1.0.0", + "name": "discord-bot-saas-platform", + "version": "2.0.0", "private": true, "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], "engines": { - "node": ">=18.17.0" + "node": ">=20.0.0" }, "scripts": { - "dev": "tsx watch src/index.ts", - "build": "npm run typecheck && node scripts/build.mjs", - "migrate": "node scripts/migrate.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --import tsx --test tests/**/*.test.ts", - "check": "npm run typecheck && npm run test", - "start": "npm run migrate && node build/index.js" - }, - "dependencies": { - "@napi-rs/canvas": "^0.1.97", - "discord.js": "14.26.2", - "dotenv": "16.4.5", - "ioredis": "^5.10.1", - "pg": "^8.20.0", - "pino": "^10.3.1", - "zod": "3.23.8" + "build": "npm run build --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present", + "dev:api": "npm run build -w @saas/shared && npm run dev -w @saas/api", + "dev:bot": "npm run build -w @saas/shared && npm run dev -w @saas/bot", + "dev:web": "npm run dev -w @saas/web", + "migrate": "npm run migrate -w @saas/api", + "start:api": "npm run start -w @saas/api", + "start:bot": "npm run start -w @saas/bot", + "start:web": "npm run start -w @saas/web", + "check": "npm run typecheck" }, "devDependencies": { - "@types/node": "20.17.24", - "@types/pg": "^8.20.0", - "esbuild": "^0.25.12", - "tsx": "4.19.2", - "typescript": "5.8.2" + "@types/node": "^22.10.7", + "tsx": "^4.19.3", + "typescript": "^5.7.3" } } diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..4cf27ec --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 0000000..b84a3ef --- /dev/null +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..06a2f87 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from "./constants.js"; +export * from "./redisKeys.js"; +export * from "./tokenCrypto.js"; +export * from "./types.js"; diff --git a/packages/shared/src/redisKeys.ts b/packages/shared/src/redisKeys.ts new file mode 100644 index 0000000..21b5db1 --- /dev/null +++ b/packages/shared/src/redisKeys.ts @@ -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); +}; diff --git a/packages/shared/src/tokenCrypto.ts b/packages/shared/src/tokenCrypto.ts new file mode 100644 index 0000000..a69d573 --- /dev/null +++ b/packages/shared/src/tokenCrypto.ts @@ -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"); +}; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..f286442 --- /dev/null +++ b/packages/shared/src/types.ts @@ -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; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..7a46543 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/build.mjs b/scripts/build.mjs deleted file mode 100644 index 21316f6..0000000 --- a/scripts/build.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { cp, mkdir, readdir, rm } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { build } from "esbuild"; - -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); -const OUTPUT_DIR = path.join(ROOT_DIR, "build"); -const SOURCE_I18N_DIR = path.join(ROOT_DIR, "src", "i18n"); -const OUTPUT_I18N_DIR = path.join(OUTPUT_DIR, "i18n"); - -const main = async () => { - await rm(OUTPUT_DIR, { recursive: true, force: true }); - - await build({ - absWorkingDir: ROOT_DIR, - entryPoints: ["src/index.ts"], - outfile: "build/index.js", - bundle: true, - packages: "external", - minify: true, - legalComments: "none", - platform: "node", - format: "esm", - target: ["node18"], - tsconfig: "tsconfig.json", - logLevel: "info", - }); - - await mkdir(OUTPUT_I18N_DIR, { recursive: true }); - const localeEntries = await readdir(SOURCE_I18N_DIR, { withFileTypes: true }); - for (const entry of localeEntries) { - if (!entry.isFile() || !entry.name.endsWith(".json")) { - continue; - } - - await cp(path.join(SOURCE_I18N_DIR, entry.name), path.join(OUTPUT_I18N_DIR, entry.name), { force: true }); - } -}; - -main().catch((error) => { - console.error("[build] failed", error); - process.exit(1); -}); diff --git a/tests/argTokenizer.test.ts b/tests/argTokenizer.test.ts deleted file mode 100644 index caeb4ce..0000000 --- a/tests/argTokenizer.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { tokenizePrefixInput } from "../src/core/commands/argParser.js"; - -test("tokenizePrefixInput parse les quotes simples et doubles", () => { - const tokens = tokenizePrefixInput('"hello world" test \'foo bar\''); - assert.deepEqual(tokens, ["hello world", "test", "foo bar"]); -}); - -test("tokenizePrefixInput conserve les tokens non quotes", () => { - const tokens = tokenizePrefixInput("alpha beta gamma"); - assert.deepEqual(tokens, ["alpha", "beta", "gamma"]); -}); - -test("tokenizePrefixInput gere les quotes echappees", () => { - const tokens = tokenizePrefixInput('"say \\"hello\\"" done'); - assert.deepEqual(tokens, ['say "hello"', "done"]); -}); diff --git a/tests/defineCommand.test.ts b/tests/defineCommand.test.ts deleted file mode 100644 index 61fb16c..0000000 --- a/tests/defineCommand.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { defineCommand } from "../src/core/commands/defineCommand.js"; - -test("defineCommand refuse un argument requis apres un optionnel", () => { - assert.throws(() => { - defineCommand({ - meta: { name: "broken", category: "test" }, - args: [ - { name: "optional", type: "string", required: false, descriptionKey: "args.optional" }, - { name: "required", type: "string", required: true, descriptionKey: "args.required" }, - ], - execute: async () => undefined, - }); - }); -}); - -test("defineCommand refuse un cooldown invalide", () => { - assert.throws(() => { - defineCommand({ - meta: { name: "brokenCooldown", category: "test" }, - cooldown: 0, - execute: async () => undefined, - }); - }); -}); - -test("defineCommand applique les valeurs par defaut", () => { - const command = defineCommand({ - meta: { name: "ok", category: "test" }, - execute: async () => undefined, - }); - - assert.deepEqual(command.args, []); - assert.deepEqual(command.permissions, []); - assert.deepEqual(command.examples, []); - assert.equal(command.sensitive, false); - assert.equal(command.cooldown, undefined); -}); - -test("defineCommand refuse une commande sensible sans permission", () => { - assert.throws(() => { - defineCommand({ - meta: { name: "sensitiveNoPerm", category: "test" }, - sensitive: true, - execute: async () => undefined, - }); - }); -}); - -test("defineCommand marque sensible automatiquement si permissions presentes", () => { - const command = defineCommand({ - meta: { name: "secured", category: "test" }, - permissions: ["ManageGuild"], - execute: async () => undefined, - }); - - assert.equal(command.sensitive, true); -}); diff --git a/tests/executionStores.test.ts b/tests/executionStores.test.ts deleted file mode 100644 index 21778e7..0000000 --- a/tests/executionStores.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { MemoryCooldownStore } from "../src/core/execution/cooldownStore.js"; -import { MemoryGlobalRateLimitStore } from "../src/core/execution/globalRateLimitStore.js"; - -test("MemoryCooldownStore bloque les executions pendant la fenetre", async () => { - const store = new MemoryCooldownStore(); - - const first = await store.consume("bot-1", "ping", "user-1", 10); - assert.equal(first.allowed, true); - assert.equal(first.retryAfterSeconds, 0); - - const second = await store.consume("bot-1", "ping", "user-1", 10); - assert.equal(second.allowed, false); - assert.ok(second.retryAfterSeconds > 0); -}); - -test("MemoryCooldownStore isole les cooldowns par bot", async () => { - const store = new MemoryCooldownStore(); - - const firstBot = await store.consume("bot-a", "ping", "user-1", 10); - const secondBot = await store.consume("bot-b", "ping", "user-1", 10); - const sameBotAgain = await store.consume("bot-a", "ping", "user-1", 10); - - assert.equal(firstBot.allowed, true); - assert.equal(secondBot.allowed, true); - assert.equal(sameBotAgain.allowed, false); -}); - -test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async () => { - const store = new MemoryGlobalRateLimitStore(); - const policy = { - limit: 2, - windowSeconds: 30, - }; - - const first = await store.consume("bot-1", "user-rl", policy); - assert.equal(first.allowed, true); - assert.equal(first.remaining, 1); - - const second = await store.consume("bot-1", "user-rl", policy); - assert.equal(second.allowed, true); - assert.equal(second.remaining, 0); - - const third = await store.consume("bot-1", "user-rl", policy); - assert.equal(third.allowed, false); - assert.ok(third.retryAfterSeconds > 0); -}); - -test("MemoryGlobalRateLimitStore isole les compteurs par bot", async () => { - const store = new MemoryGlobalRateLimitStore(); - const policy = { - limit: 1, - windowSeconds: 30, - }; - - const botAFirst = await store.consume("bot-a", "user-rl", policy); - const botBFirst = await store.consume("bot-b", "user-rl", policy); - const botASecond = await store.consume("bot-a", "user-rl", policy); - - assert.equal(botAFirst.allowed, true); - assert.equal(botBFirst.allowed, true); - assert.equal(botASecond.allowed, false); -}); diff --git a/tests/logsTypes.test.ts b/tests/logsTypes.test.ts deleted file mode 100644 index ea7dd97..0000000 --- a/tests/logsTypes.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { LOG_EVENT_KEYS } from "../src/features/logs/catalog.js"; -import { - createDefaultLogEventState, - isLogEventKey, - mergeLogEventRowsIntoState, -} from "../src/validators/logs.js"; - -test("createDefaultLogEventState initialise tous les events desactives", () => { - const state = createDefaultLogEventState(); - - assert.equal(Object.keys(state).length, LOG_EVENT_KEYS.length); - for (const eventKey of LOG_EVENT_KEYS) { - assert.equal(state[eventKey].enabled, false); - assert.equal(state[eventKey].channelId, null); - } -}); - -test("isLogEventKey valide les cles connues", () => { - assert.equal(isLogEventKey("messageCreate"), true); - assert.equal(isLogEventKey("not-an-event"), false); -}); - -test("mergeLogEventRowsIntoState fusionne les valeurs stockees", () => { - const state = mergeLogEventRowsIntoState([ - { - event_key: "messageCreate", - enabled: true, - channel_id: "123", - }, - { - event_key: "not-existing", - enabled: true, - channel_id: "999", - }, - ]); - - assert.equal(state.messageCreate.enabled, true); - assert.equal(state.messageCreate.channelId, "123"); - assert.equal(state.messageDelete.enabled, false); - assert.equal(state.messageDelete.channelId, null); -}); \ No newline at end of file diff --git a/tests/memberMessagesTypes.test.ts b/tests/memberMessagesTypes.test.ts deleted file mode 100644 index da83ced..0000000 --- a/tests/memberMessagesTypes.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - createDefaultMemberMessageConfig, - sanitizeMemberMessageRoleIds, -} from "../src/validators/memberMessages.js"; - -test("createDefaultMemberMessageConfig initialise les roles auto vides", () => { - const config = createDefaultMemberMessageConfig(); - - assert.equal(config.enabled, false); - assert.equal(config.channelId, null); - assert.equal(config.messageType, "simple"); - assert.deepEqual(config.autoRoleIds, []); -}); - -test("sanitizeMemberMessageRoleIds filtre les valeurs invalides et dedupe", () => { - const roleIds = sanitizeMemberMessageRoleIds([ - "123456789012345678", - "123456789012345678", - " 223456789012345678 ", - "not-a-role", - "", - ]); - - assert.deepEqual(roleIds, ["123456789012345678", "223456789012345678"]); -}); diff --git a/tests/presenceTypes.test.ts b/tests/presenceTypes.test.ts deleted file mode 100644 index 07917ac..0000000 --- a/tests/presenceTypes.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEFAULT_ACTIVITY_TEXT, - MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, - MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, - parsePresenceState, - sanitizeActivityTexts, - sanitizePresenceRotationIntervalSeconds, -} from "../src/validators/presence.js"; - -test("sanitizeActivityTexts fallback sur le texte par defaut", () => { - const texts = sanitizeActivityTexts([" ", ""]); - assert.deepEqual(texts, [DEFAULT_ACTIVITY_TEXT]); -}); - -test("sanitizePresenceRotationIntervalSeconds borne les valeurs", () => { - assert.equal(sanitizePresenceRotationIntervalSeconds(1), MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS); - assert.equal( - sanitizePresenceRotationIntervalSeconds(MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS + 100), - MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, - ); -}); - -test("parsePresenceState reconstruit un etat valide", () => { - const parsed = parsePresenceState({ - status: "online", - activity: { - type: "PLAYING", - texts: ["Hello", "World"], - rotationIntervalSeconds: 20, - }, - }); - - assert.ok(parsed); - assert.equal(parsed?.status, "online"); - assert.equal(parsed?.activity.type, "PLAYING"); - assert.deepEqual(parsed?.activity.texts, ["Hello", "World"]); - assert.equal(parsed?.activity.rotationIntervalSeconds, 20); -}); - -test("parsePresenceState retourne null sur statut invalide", () => { - const parsed = parsePresenceState({ - status: "invalid", - activity: { - type: "PLAYING", - texts: ["Hello"], - rotationIntervalSeconds: 20, - }, - }); - - assert.equal(parsed, null); -}); diff --git a/tests/templateVariables.test.ts b/tests/templateVariables.test.ts deleted file mode 100644 index 3375aa7..0000000 --- a/tests/templateVariables.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - extractTemplateVariables, - hasTemplateVariable, - renderTemplate, -} from "../src/utils/templateVariables.js"; - -test("renderTemplate remplace les variables et applique les alias", () => { - const output = renderTemplate( - "Hello {{name}} from {{guilds}}", - { - name: "Arthur", - guild_count: "42", - }, - { - aliases: { - guilds: "guild_count", - }, - }, - ); - - assert.equal(output, "Hello Arthur from 42"); -}); - -test("renderTemplate conserve les variables inconnues par defaut", () => { - const output = renderTemplate("Missing {{unknown}}", {}, { keepUnknown: true }); - assert.equal(output, "Missing {{unknown}}"); -}); - -test("extractTemplateVariables normalise les alias", () => { - const variables = extractTemplateVariables("{{bot}} {{guilds}}", { - bot: "bot_name", - guilds: "guild_count", - }); - - assert.deepEqual(variables.sort(), ["bot_name", "guild_count"]); -}); - -test("hasTemplateVariable detecte les variables connues", () => { - const hasKnown = hasTemplateVariable("{{prefix}} {{other}}", ["prefix", "guild_count"]); - assert.equal(hasKnown, true); - - const hasUnknownOnly = hasTemplateVariable("{{other}}", ["prefix", "guild_count"]); - assert.equal(hasUnknownOnly, false); -}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ddb8577 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "sourceMap": true + } +} diff --git a/tsconfig.json b/tsconfig.json index d5bf092..4cd944c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,14 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": "src", - "outDir": "build", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true - }, - "include": ["src/**/*.ts"] + "files": [], + "references": [ + { + "path": "packages/shared" + }, + { + "path": "apps/api" + }, + { + "path": "apps/bot" + } + ] }