diff --git a/.env.self-host.example b/.env.self-host.example index 90a2df3..b9db920 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com # Default: true # AUTO_PROJECT_DISABLE=false +# ======================================== +# OPTIONAL: Self-Hosting User Management +# ======================================== +# Controls whether new user signups are allowed +# When enabled (true), the signup endpoint will reject new user registration attempts +# Useful for private instances or when you want to manually manage users +# Default: false +# DISABLE_SIGNUPS=false + +# Controls whether email validation checks are performed on signup +# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records +# When disabled (false), skips these validation checks and allows any email format +# Default: false +# VERIFY_EMAIL_ON_SIGNUP=false + # ======================================== # ADVANCED (rarely needed) # ======================================== diff --git a/CLAUDE.md b/CLAUDE.md index 65b9299..5bdfdda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,9 +64,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker` ### Applications (`apps/`) - **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core - - HTTP API endpoints for the platform - - Background cron jobs (workflow processor, domain verification) - - **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues + - HTTP API endpoints for the platform + - Background cron jobs (workflow processor, domain verification) + - **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues - **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com) - **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com) - **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com) @@ -148,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example): - Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications) - Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like project disabled, billing limits, etc. If not set, only ntfy notifications are sent) +- Self-hosting User Management (optional): + - `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API + - `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains, + plus-addressing, domain existence, and MX records +- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when + bounce/complaint rate thresholds are exceeded **Important Notes:** @@ -159,7 +165,8 @@ Required for builds and deployment (see turbo.json and .env.example): client-side access ## Plugins + There are two plugins installed for you to use. - frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks. -- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding. \ No newline at end of file +- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding. diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index 2d8c872..06c5b2f 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== ''; // Useful for self-hosters who want to manage project status manually export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true'; +// Self-hosting Configuration (optional) +// Controls whether new user signups are allowed (default: false) +export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true'; +// Controls whether email validation checks are performed on signup (default: false) +export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true'; + // Email Verification & Password Reset export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index 2acf986..c6b7b66 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -7,6 +7,7 @@ import * as React from 'react'; import { DASHBOARD_URI, + DISABLE_SIGNUPS, EMAIL_VERIFICATION_RATE_LIMIT, EMAIL_VERIFICATION_RATE_WINDOW, GITHUB_OAUTH_ENABLED, @@ -15,6 +16,7 @@ import { PASSWORD_RESET_RATE_LIMIT, PLUNK_ENABLED, TOKEN_EXPIRY_SECONDS, + VERIFY_EMAIL_ON_SIGNUP, } from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {redis, REDIS_ONE_MINUTE} from '../database/redis.js'; @@ -63,33 +65,43 @@ export class Auth { @Post('signup') @CatchAsync public async signup(req: Request, res: Response, _next: NextFunction) { - const {email, password} = AuthenticationSchemas.login.parse(req.body); - - // Verify email is valid and not disposable/plus-addressed - const verification = await EmailVerificationService.verifyEmail(email); - - if ( - verification.isDisposable || - verification.isPlusAddressed || - !verification.domainExists || - !verification.hasMxRecords - ) { - // Build list of reasons for notification - const reasons: string[] = []; - if (verification.isDisposable) reasons.push('disposable email'); - if (verification.isPlusAddressed) reasons.push('plus addressing'); - if (!verification.domainExists) reasons.push('domain does not exist'); - if (!verification.hasMxRecords) reasons.push('no MX records'); - - // Send notification about failed signup attempt - await NtfyService.notifyFailedSignupAttempt(email, reasons); - + // Check if signups are disabled + if (DISABLE_SIGNUPS) { return res.json({ success: false, - data: 'This email address cannot be used for signup', + data: 'New user signups are currently disabled', }); } + const {email, password} = AuthenticationSchemas.login.parse(req.body); + + // Verify email is valid and not disposable/plus-addressed (if verification enabled) + if (VERIFY_EMAIL_ON_SIGNUP) { + const verification = await EmailVerificationService.verifyEmail(email); + + if ( + verification.isDisposable || + verification.isPlusAddressed || + !verification.domainExists || + !verification.hasMxRecords + ) { + // Build list of reasons for notification + const reasons: string[] = []; + if (verification.isDisposable) reasons.push('disposable email'); + if (verification.isPlusAddressed) reasons.push('plus addressing'); + if (!verification.domainExists) reasons.push('domain does not exist'); + if (!verification.hasMxRecords) reasons.push('no MX records'); + + // Send notification about failed signup attempt + await NtfyService.notifyFailedSignupAttempt(email, reasons); + + return res.json({ + success: false, + data: 'This email address cannot be used for signup', + }); + } + } + const user = await UserService.email(email); if (user) { diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts index a25bbb0..74f4406 100644 --- a/apps/api/src/controllers/Oauth/Github.ts +++ b/apps/api/src/controllers/Oauth/Github.ts @@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express'; import { API_URI, DASHBOARD_URI, + DISABLE_SIGNUPS, GITHUB_OAUTH_CLIENT, GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_SECRET, } from '../../app/constants.js'; import {prisma} from '../../database/prisma.js'; +import {BadRequest} from '../../exceptions/index.js'; import {jwt} from '../../middleware/auth.js'; import {NtfyService} from '../../services/NtfyService.js'; import {UserService} from '../../services/UserService.js'; @@ -81,6 +83,11 @@ export class Github { let isNewUser = false; if (!user) { + // Check if signups are disabled + if (DISABLE_SIGNUPS) { + throw new BadRequest('New user signups are currently disabled'); + } + user = await prisma.user.create({ data: { email, diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts index 5c2a3ce..7c070f6 100644 --- a/apps/api/src/controllers/Oauth/Google.ts +++ b/apps/api/src/controllers/Oauth/Google.ts @@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express'; import { API_URI, DASHBOARD_URI, + DISABLE_SIGNUPS, GOOGLE_OAUTH_CLIENT, GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_SECRET, } from '../../app/constants.js'; import {prisma} from '../../database/prisma.js'; +import {BadRequest} from '../../exceptions/index.js'; import {jwt} from '../../middleware/auth.js'; import {NtfyService} from '../../services/NtfyService.js'; import {UserService} from '../../services/UserService.js'; @@ -71,6 +73,11 @@ export class Google { let isNewUser = false; if (!user) { + // Check if signups are disabled + if (DISABLE_SIGNUPS) { + throw new BadRequest('New user signups are currently disabled'); + } + user = await prisma.user.create({ data: { email,