feat: Audit remediation + Stripe webhook + test suites
Build and Push Docker Image / lint-and-typecheck (push) Failing after 1m47s Details
Build and Push Docker Image / build (push) Has been skipped Details

- Apply 3 Prisma schema changes (Pending2FASession, hubApiKeyHash, SecurityVerificationCode attempts)
- Add Stripe webhook handler (checkout.session.completed -> User + Subscription + Order)
- Add stripe-service, api-key-service, rate-limit middleware
- Add security headers (CSP, HSTS, X-Frame-Options) in next.config.ts
- Harden auth routes, require ADMIN_API_KEY for orchestrator endpoints
- Add Docker auto-migration via startup.sh
- Add 7 unit test suites (api-key, dns, config-generator, automation-worker, permission, security-verification, auth-helpers)
- Fix Prisma 7 compatibility with adapter-pg mock for vitest

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-07 08:02:33 +01:00
parent bcc1e17934
commit 1c96c3a85e
36 changed files with 3255 additions and 224 deletions

View File

@ -1,7 +1,7 @@
# LetsBe Hub Configuration # LetsBe Hub Configuration
# Database # Database
DATABASE_URL=postgresql+asyncpg://hub:hub@db:5432/hub DATABASE_URL=postgresql://hub:hub@db:5432/hub
# Admin API Key (CHANGE IN PRODUCTION!) # Admin API Key (CHANGE IN PRODUCTION!)
ADMIN_API_KEY=change-me-in-production ADMIN_API_KEY=change-me-in-production
@ -11,3 +11,24 @@ DEBUG=false
# Telemetry retention (days) # Telemetry retention (days)
TELEMETRY_RETENTION_DAYS=90 TELEMETRY_RETENTION_DAYS=90
# =============================================================================
# Email (Resend)
# =============================================================================
# API key from https://resend.com
# RESEND_API_KEY=re_xxxxxxxxxx
# Sender email address (must be verified in Resend)
# RESEND_FROM_EMAIL=noreply@yourdomain.com
# =============================================================================
# Cron / Scheduled Tasks
# =============================================================================
# Secret used to authenticate cron job requests
# Generate with: openssl rand -hex 32
# CRON_SECRET=
# =============================================================================
# Public API
# =============================================================================
# API key exposed to client-side code (non-sensitive, for rate limiting etc.)
# PUBLIC_API_KEY=

2
.gitignore vendored
View File

@ -18,8 +18,10 @@ coverage/
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
deploy/.env
!.env.example !.env.example
!.env.local.example !.env.local.example
!deploy/.env.example
# IDE # IDE
.idea/ .idea/

View File

@ -9,8 +9,9 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
# Generate Prisma Client # Generate Prisma Client (Prisma 7 uses prisma.config.mjs for datasource URL)
COPY prisma ./prisma/ COPY prisma ./prisma/
COPY prisma.config.mjs ./
RUN npx prisma generate RUN npx prisma generate
# Rebuild the source code only when needed # Rebuild the source code only when needed
@ -61,14 +62,20 @@ RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma (client, schema, and config for migrations) # Copy Prisma client and schema (for runtime + migrations)
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
COPY prisma ./prisma/ COPY prisma ./prisma/
COPY prisma.config.mjs ./ COPY prisma.config.mjs ./
# Install Prisma CLI and dotenv globally for migrations # Install Prisma CLI globally for running migrations on startup
RUN npm install -g prisma@7 dotenv # (copying just node_modules/prisma misses transitive deps like valibot)
RUN npm install -g prisma@7
# Copy startup script (runs migrations before starting app)
# Use tr to strip Windows CRLF line endings (more reliable than sed on Alpine)
COPY startup.sh /tmp/startup.sh
RUN tr -d '\r' < /tmp/startup.sh > startup.sh && chmod +x startup.sh && rm /tmp/startup.sh
USER nextjs USER nextjs
@ -77,4 +84,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["./startup.sh"]

View File

@ -36,6 +36,13 @@ services:
CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only
# Encryption key for settings service (SMTP passwords, tokens, etc.) # Encryption key for settings service (SMTP passwords, tokens, etc.)
SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only
# Email sending via Resend (optional in dev)
# RESEND_API_KEY: ""
# RESEND_FROM_EMAIL: ""
# Cron job secret for scheduled tasks
# CRON_SECRET: ""
# Public API key for client-side usage
# PUBLIC_API_KEY: ""
# Host paths for job config files (used when spawning runner containers) # Host paths for job config files (used when spawning runner containers)
# On Windows with Docker Desktop, use /c/Repos/... format # On Windows with Docker Desktop, use /c/Repos/... format
JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs

View File

@ -1,5 +1,50 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.letsbe.solutions",
"font-src 'self' data:",
"connect-src 'self' https://*.letsbe.solutions",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
]
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
// reactCompiler: true, // Requires babel-plugin-react-compiler - enable later // reactCompiler: true, // Requires babel-plugin-react-compiler - enable later
@ -31,6 +76,14 @@ const nextConfig: NextConfig = {
}, },
// Externalize ssh2 for both Turbopack and Webpack // Externalize ssh2 for both Turbopack and Webpack
serverExternalPackages: ['ssh2'], serverExternalPackages: ['ssh2'],
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
} }
export default nextConfig export default nextConfig

View File

@ -56,6 +56,7 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"stripe": "^17.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"undici": "^7.18.2", "undici": "^7.18.2",

View File

@ -0,0 +1,34 @@
-- AlterTable: Add brute-force attempt tracking to security verification codes
ALTER TABLE "security_verification_codes" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: Add hash-based API key lookup to server connections
ALTER TABLE "server_connections" ADD COLUMN "hub_api_key_hash" TEXT;
-- CreateTable: DB-backed 2FA sessions (replacing in-memory Map)
CREATE TABLE "pending_2fa_sessions" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"user_type" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" TEXT,
"company" TEXT,
"subscription" JSONB,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pending_2fa_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "pending_2fa_sessions_token_key" ON "pending_2fa_sessions"("token");
-- CreateIndex
CREATE INDEX "pending_2fa_sessions_token_idx" ON "pending_2fa_sessions"("token");
-- CreateIndex
CREATE INDEX "pending_2fa_sessions_expires_at_idx" ON "pending_2fa_sessions"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_hub_api_key_hash_key" ON "server_connections"("hub_api_key_hash");

View File

@ -7,7 +7,7 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
// url configured in prisma.config.ts (Prisma 7+) // url configured in prisma.config.mjs (Prisma 7+)
} }
// ============================================================================ // ============================================================================
@ -417,6 +417,7 @@ model ServerConnection {
// Hub API key (issued after successful registration, used for heartbeats/commands) // Hub API key (issued after successful registration, used for heartbeats/commands)
hubApiKey String? @unique @map("hub_api_key") hubApiKey String? @unique @map("hub_api_key")
hubApiKeyHash String? @unique @map("hub_api_key_hash")
// Orchestrator connection info (provided during registration) // Orchestrator connection info (provided during registration)
orchestratorUrl String? @map("orchestrator_url") orchestratorUrl String? @map("orchestrator_url")
@ -613,11 +614,12 @@ model DetectedError {
model SecurityVerificationCode { model SecurityVerificationCode {
id String @id @default(cuid()) id String @id @default(cuid())
clientId String @map("client_id") clientId String @map("client_id")
code String // 6-digit code code String // 8-digit code
action String // "WIPE" | "REINSTALL" action String // "WIPE" | "REINSTALL"
targetServerId String @map("target_server_id") // Which server targetServerId String @map("target_server_id") // Which server
expiresAt DateTime @map("expires_at") expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at") usedAt DateTime? @map("used_at")
attempts Int @default(0) // Failed verification attempts
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
// Relations // Relations
@ -705,6 +707,28 @@ model NotificationSetting {
@@map("notification_settings") @@map("notification_settings")
} }
// ============================================================================
// PENDING 2FA SESSIONS
// ============================================================================
model Pending2FASession {
id String @id @default(cuid())
token String @unique
userId String @map("user_id")
userType String @map("user_type") // 'customer' | 'staff'
email String
name String?
role String? // StaffRole for staff users
company String?
subscription Json? // Subscription data for customer users
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([token])
@@index([expiresAt])
@@map("pending_2fa_sessions")
}
// ============================================================================ // ============================================================================
// SYSTEM-WIDE NOTIFICATION COOLDOWN // SYSTEM-WIDE NOTIFICATION COOLDOWN
// ============================================================================ // ============================================================================

View File

@ -0,0 +1,10 @@
/**
* Mock for @prisma/adapter-pg (Prisma 7 driver adapter).
* The actual adapter isn't available locally on Node 23,
* but tests mock the entire prisma module anyway.
*/
export class PrismaPg {
constructor(_opts: { connectionString: string }) {
// no-op mock
}
}

View File

@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StaffRole } from '@prisma/client'
// Mock the auth module
const mockAuth = vi.fn()
vi.mock('@/lib/auth', () => ({
auth: mockAuth,
}))
// Mock the permission service
vi.mock('@/lib/services/permission-service', () => ({
hasPermission: vi.fn((role: string, permission: string) => {
// Simplified permission check for testing
const ROLE_PERMISSIONS: Record<string, string[]> = {
OWNER: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'orders:delete', 'customers:view', 'customers:create',
'customers:edit', 'customers:delete', 'servers:view', 'servers:power',
'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite',
'staff:manage', 'staff:delete', 'settings:view', 'settings:edit',
'enterprise:view', 'enterprise:manage',
],
ADMIN: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'orders:delete', 'customers:view', 'customers:create',
'customers:edit', 'customers:delete', 'servers:view', 'servers:power',
'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite',
'staff:manage', 'settings:view', 'settings:edit',
'enterprise:view', 'enterprise:manage',
],
MANAGER: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'customers:view', 'customers:create', 'customers:edit',
'servers:view', 'servers:power', 'servers:snapshots', 'servers:rescue',
'enterprise:view', 'enterprise:manage',
],
SUPPORT: [
'dashboard:view', 'orders:view', 'customers:view', 'servers:view',
'enterprise:view',
],
}
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false
}),
}))
import { requireStaffPermission, requireStaffSession } from '@/lib/auth-helpers'
describe('Auth Helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('requireStaffPermission', () => {
it('should throw 401 when no session exists', async () => {
mockAuth.mockResolvedValue(null)
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 401 when session has no user', async () => {
mockAuth.mockResolvedValue({ user: null })
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 403 when user is not staff', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'user-123',
email: 'customer@example.com',
name: 'Customer',
userType: 'customer',
},
})
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Staff access required')
}
})
it('should throw 403 when staff lacks required permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-123',
email: 'support@letsbe.cloud',
name: 'Support Staff',
userType: 'staff',
role: 'SUPPORT',
},
})
try {
await requireStaffPermission('staff:delete')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Insufficient permissions')
}
})
it('should return session for OWNER with any permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-owner',
email: 'owner@letsbe.cloud',
name: 'Owner',
userType: 'staff',
role: 'OWNER',
},
})
const session = await requireStaffPermission('staff:delete')
expect(session.user.id).toBe('staff-owner')
expect(session.user.role).toBe('OWNER')
expect(session.user.userType).toBe('staff')
})
it('should return session for ADMIN with orders:view permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-admin',
email: 'admin@letsbe.cloud',
name: 'Admin',
userType: 'staff',
role: 'ADMIN',
},
})
const session = await requireStaffPermission('orders:view')
expect(session.user.id).toBe('staff-admin')
expect(session.user.role).toBe('ADMIN')
})
it('should deny ADMIN staff:delete permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-admin',
email: 'admin@letsbe.cloud',
name: 'Admin',
userType: 'staff',
role: 'ADMIN',
},
})
try {
await requireStaffPermission('staff:delete')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Insufficient permissions')
}
})
it('should allow MANAGER orders:create permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-manager',
email: 'manager@letsbe.cloud',
name: 'Manager',
userType: 'staff',
role: 'MANAGER',
},
})
const session = await requireStaffPermission('orders:create')
expect(session.user.role).toBe('MANAGER')
})
it('should deny MANAGER settings:edit permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-manager',
email: 'manager@letsbe.cloud',
name: 'Manager',
userType: 'staff',
role: 'MANAGER',
},
})
try {
await requireStaffPermission('settings:edit')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
}
})
it('should deny SUPPORT orders:create permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
try {
await requireStaffPermission('orders:create')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
}
})
it('should allow SUPPORT orders:view permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
const session = await requireStaffPermission('orders:view')
expect(session.user.role).toBe('SUPPORT')
})
it('should set email to empty string when not provided', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-123',
email: null,
name: 'No Email',
userType: 'staff',
role: 'OWNER',
},
})
const session = await requireStaffPermission('dashboard:view')
expect(session.user.email).toBe('')
})
})
describe('requireStaffSession', () => {
it('should throw 401 when no session exists', async () => {
mockAuth.mockResolvedValue(null)
try {
await requireStaffSession()
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 403 when user is not staff', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'customer-123',
email: 'customer@example.com',
userType: 'customer',
},
})
try {
await requireStaffSession()
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Staff access required')
}
})
it('should return session for any staff role without permission check', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
const session = await requireStaffSession()
expect(session.user.id).toBe('staff-support')
expect(session.user.userType).toBe('staff')
expect(session.user.role).toBe('SUPPORT')
})
})
})

View File

@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import crypto from 'crypto'
// Import after prisma mock is set up
import { apiKeyService } from '@/lib/services/api-key-service'
describe('ApiKeyService', () => {
beforeEach(() => {
resetPrismaMock()
})
describe('generateKey', () => {
it('should generate a key with hk_ prefix', () => {
const key = apiKeyService.generateKey()
expect(key).toMatch(/^hk_/)
})
it('should generate a 68-character key (hk_ + 64 hex chars)', () => {
const key = apiKeyService.generateKey()
// "hk_" (3 chars) + 32 bytes hex (64 chars) = 67 chars
expect(key).toHaveLength(67)
})
it('should generate unique keys each time', () => {
const key1 = apiKeyService.generateKey()
const key2 = apiKeyService.generateKey()
expect(key1).not.toBe(key2)
})
it('should generate key with valid hex after prefix', () => {
const key = apiKeyService.generateKey()
const hexPart = key.slice(3)
expect(hexPart).toMatch(/^[0-9a-f]+$/)
})
})
describe('hashKey', () => {
it('should return a SHA-256 hex hash', () => {
const key = 'hk_test-api-key'
const hash = apiKeyService.hashKey(key)
// SHA-256 produces 64 hex chars
expect(hash).toHaveLength(64)
expect(hash).toMatch(/^[0-9a-f]+$/)
})
it('should produce consistent hashes for the same input', () => {
const key = 'hk_same-key'
const hash1 = apiKeyService.hashKey(key)
const hash2 = apiKeyService.hashKey(key)
expect(hash1).toBe(hash2)
})
it('should produce different hashes for different inputs', () => {
const hash1 = apiKeyService.hashKey('hk_key-one')
const hash2 = apiKeyService.hashKey('hk_key-two')
expect(hash1).not.toBe(hash2)
})
it('should match Node.js crypto SHA-256 output', () => {
const key = 'hk_verification-key'
const expectedHash = crypto.createHash('sha256').update(key).digest('hex')
const hash = apiKeyService.hashKey(key)
expect(hash).toBe(expectedHash)
})
})
describe('findByApiKey', () => {
const mockConnection = {
id: 'conn-123',
hubApiKey: 'hk_plain-key',
hubApiKeyHash: null,
orderId: 'order-456',
order: {
id: 'order-456',
domain: 'test.letsbe.cloud',
serverIp: '192.168.1.100',
},
}
it('should find connection by hash (preferred path)', async () => {
const apiKey = 'hk_test-key'
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKeyHash: hash,
} as any)
const result = await apiKeyService.findByApiKey(apiKey)
expect(result).toBeDefined()
// First call should be hash lookup
expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledWith({
where: { hubApiKeyHash: hash },
include: {
order: {
select: {
id: true,
domain: true,
serverIp: true,
},
},
},
})
})
it('should fallback to plaintext lookup when hash not found', async () => {
const apiKey = 'hk_legacy-key'
// First call (hash lookup) returns null
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Second call (plaintext lookup) returns the connection
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKey: apiKey,
hubApiKeyHash: null,
} as any)
prismaMock.serverConnection.update.mockResolvedValue({} as any)
const result = await apiKeyService.findByApiKey(apiKey)
expect(result).toBeDefined()
// Should have been called twice: hash lookup, then plaintext
expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledTimes(2)
expect(prismaMock.serverConnection.findUnique).toHaveBeenNthCalledWith(2, {
where: { hubApiKey: apiKey },
include: expect.any(Object),
})
})
it('should migrate plaintext key to hash when found via fallback', async () => {
const apiKey = 'hk_migrate-me'
const expectedHash = crypto.createHash('sha256').update(apiKey).digest('hex')
// Hash lookup fails
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Plaintext lookup succeeds with no hash
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
id: 'conn-migrate',
hubApiKey: apiKey,
hubApiKeyHash: null,
} as any)
prismaMock.serverConnection.update.mockResolvedValue({} as any)
await apiKeyService.findByApiKey(apiKey)
// Should have triggered migration update
expect(prismaMock.serverConnection.update).toHaveBeenCalledWith({
where: { id: 'conn-migrate' },
data: { hubApiKeyHash: expectedHash },
})
})
it('should not migrate if hash already exists', async () => {
const apiKey = 'hk_already-migrated'
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
// Hash lookup fails
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Plaintext lookup succeeds but hash already set
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKey: apiKey,
hubApiKeyHash: hash,
} as any)
await apiKeyService.findByApiKey(apiKey)
// Should NOT have triggered migration
expect(prismaMock.serverConnection.update).not.toHaveBeenCalled()
})
it('should return null when key not found by either method', async () => {
prismaMock.serverConnection.findUnique.mockResolvedValue(null)
const result = await apiKeyService.findByApiKey('hk_nonexistent')
expect(result).toBeNull()
})
})
})

View File

@ -0,0 +1,458 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import { OrderStatus, AutomationMode, LogLevel } from '@prisma/client'
import {
processAutomation,
setAutomationMode,
resumeAutomation,
pauseAutomation,
takeManualControl,
enableAutoMode,
} from '@/lib/services/automation-worker'
describe('Automation Worker', () => {
beforeEach(() => {
resetPrismaMock()
// Suppress console.error from logAutomation error handling
vi.spyOn(console, 'error').mockImplementation(() => {})
// Mock provisioningLog.create for all tests (used by logAutomation)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
})
describe('processAutomation', () => {
it('should return not triggered when order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
const result = await processAutomation('invalid-id')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order not found')
})
it('should skip processing when mode is MANUAL', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.MANUAL,
status: OrderStatus.SERVER_READY,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('MANUAL')
})
it('should skip processing when mode is PAUSED', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.PAUSED,
status: OrderStatus.SERVER_READY,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('PAUSED')
})
it('should wait for server credentials in AWAITING_SERVER status', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.AWAITING_SERVER,
serverIp: null,
serverPasswordEncrypted: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Waiting for server credentials')
})
it('should auto-transition AWAITING_SERVER to SERVER_READY when credentials present', async () => {
// First call: AWAITING_SERVER with credentials
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.AWAITING_SERVER,
serverIp: '10.0.0.1',
serverPasswordEncrypted: 'enc-password',
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call after transition: now SERVER_READY
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
serverIp: '10.0.0.1',
serverPasswordEncrypted: 'enc-password',
dnsVerification: null,
serverConnection: null,
} as any)
await processAutomation('order-123')
// Should have updated to SERVER_READY
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.SERVER_READY,
serverReadyAt: expect.any(Date),
}),
})
})
it('should auto-transition SERVER_READY to DNS_PENDING', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('DNS_PENDING')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: { status: OrderStatus.DNS_PENDING },
})
})
it('should wait for DNS in DNS_PENDING status when not verified', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: false, manualOverride: false },
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Waiting for DNS verification')
})
it('should auto-transition DNS_PENDING to DNS_READY when verified', async () => {
// First call: DNS_PENDING with allPassed
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: true, manualOverride: false },
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call: DNS_READY
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: true, manualOverride: false },
serverConnection: null,
} as any)
await processAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
dnsVerifiedAt: expect.any(Date),
}),
})
})
it('should auto-transition DNS_PENDING to DNS_READY when manually overridden', async () => {
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: false, manualOverride: true },
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: false, manualOverride: true },
serverConnection: null,
} as any)
await processAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
}),
})
})
it('should signal ready for provisioning in DNS_READY status', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: true },
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('provision')
})
it('should report provisioning in progress', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.PROVISIONING,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Provisioning in progress')
})
it('should report order fulfilled', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('fulfilled')
})
it('should auto-pause on failure', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FAILED,
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('Paused')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedAt: expect.any(Date),
}),
})
})
})
describe('setAutomationMode', () => {
it('should update order to PAUSED with reason', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue(null) // For processAutomation if called
await setAutomationMode('order-123', AutomationMode.PAUSED, 'Test pause reason')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedAt: expect.any(Date),
automationPausedReason: 'Test pause reason',
}),
})
})
it('should clear pause info when switching to AUTO', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
// processAutomation is called after switching to AUTO
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
await setAutomationMode('order-123', AutomationMode.AUTO)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.AUTO,
automationPausedAt: null,
automationPausedReason: null,
}),
})
})
it('should clear pause info when switching to MANUAL', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await setAutomationMode('order-123', AutomationMode.MANUAL)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.MANUAL,
automationPausedAt: null,
automationPausedReason: null,
}),
})
})
it('should trigger processAutomation when switching to AUTO', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
dnsVerification: null,
serverConnection: null,
} as any)
await setAutomationMode('order-123', AutomationMode.AUTO)
// processAutomation should have been called (it reads order)
expect(prismaMock.order.findUnique).toHaveBeenCalled()
})
})
describe('resumeAutomation', () => {
it('should return error when order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
const result = await resumeAutomation('invalid-id')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order not found')
})
it('should return error when order is not paused', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.MANUAL,
} as any)
const result = await resumeAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order is not paused')
})
it('should resume paused order to AUTO mode', async () => {
// First call: check if paused
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.PAUSED,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Second call from processAutomation after setAutomationMode
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await resumeAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toBe('Automation resumed')
})
})
describe('pauseAutomation', () => {
it('should set mode to PAUSED with reason', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await pauseAutomation('order-123', 'Need to investigate')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedReason: 'Need to investigate',
}),
})
})
it('should use default reason when none provided', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await pauseAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationPausedReason: 'Manually paused by staff',
}),
})
})
})
describe('takeManualControl', () => {
it('should set mode to MANUAL', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await takeManualControl('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.MANUAL,
}),
})
})
})
describe('enableAutoMode', () => {
it('should set mode to AUTO and trigger processing', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await enableAutoMode('order-123')
expect(result.triggered).toBe(true)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.AUTO,
}),
})
})
})
})

View File

@ -0,0 +1,237 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { SubscriptionTier } from '@prisma/client'
import crypto from 'crypto'
// Mock the credential service
vi.mock('@/lib/services/credential-service', () => ({
credentialService: {
decrypt: vi.fn().mockReturnValue('decrypted-password'),
decryptLegacy: vi.fn().mockReturnValue('legacy-password'),
},
}))
import {
generateJobConfig,
generateRunnerToken,
hashRunnerToken,
verifyRunnerToken,
decryptPassword,
} from '@/lib/services/config-generator'
import { credentialService } from '@/lib/services/credential-service'
describe('Config Generator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateJobConfig', () => {
const validOrder = {
id: 'order-123',
serverIp: '192.168.1.100',
sshPort: 22,
domain: 'test.letsbe.cloud',
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: 'lb_inst_abc123',
tier: SubscriptionTier.HUB_DASHBOARD,
tools: ['orchestrator', 'sysadmin-agent', 'nextcloud'],
} as any
it('should generate valid config from order', () => {
const config = generateJobConfig(validOrder, 'my-password')
expect(config.server.ip).toBe('192.168.1.100')
expect(config.server.port).toBe(22)
expect(config.server.rootPassword).toBe('my-password')
expect(config.customer).toBe('Test Customer')
expect(config.domain).toBe('test.letsbe.cloud')
expect(config.companyName).toBe('Test Company')
expect(config.licenseKey).toBe('lb_inst_abc123')
expect(config.tools).toEqual(['orchestrator', 'sysadmin-agent', 'nextcloud'])
})
it('should map HUB_DASHBOARD tier correctly', () => {
const config = generateJobConfig(validOrder, 'password')
expect(config.dashboardTier).toBe('HUB_DASHBOARD')
})
it('should map ADVANCED tier correctly', () => {
const order = { ...validOrder, tier: SubscriptionTier.ADVANCED }
const config = generateJobConfig(order, 'password')
expect(config.dashboardTier).toBe('ADVANCED')
})
it('should default SSH port to 22 when not set', () => {
const order = { ...validOrder, sshPort: null }
const config = generateJobConfig(order, 'password')
expect(config.server.port).toBe(22)
})
it('should set hubUrl from HUB_URL env var', () => {
const original = process.env.HUB_URL
process.env.HUB_URL = 'https://hub.letsbe.cloud'
const config = generateJobConfig(validOrder, 'password')
expect(config.hubUrl).toBe('https://hub.letsbe.cloud')
process.env.HUB_URL = original
})
it('should set hubTelemetryEnabled to true', () => {
const config = generateJobConfig(validOrder, 'password')
expect(config.hubTelemetryEnabled).toBe(true)
})
it('should throw if server IP is missing', () => {
const order = { ...validOrder, serverIp: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing server IP')
})
it('should throw if customer is missing', () => {
const order = { ...validOrder, customer: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing customer identifier')
})
it('should throw if company name is missing', () => {
const order = { ...validOrder, companyName: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing company name')
})
it('should throw if license key is missing', () => {
const order = { ...validOrder, licenseKey: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing license key')
})
it('should include Docker Hub credentials when provided', () => {
const dockerHub = {
username: 'docker-user',
token: 'docker-token',
registry: 'https://registry.example.com',
}
const config = generateJobConfig(validOrder, 'password', dockerHub)
expect(config.dockerHub).toBeDefined()
expect(config.dockerHub?.username).toBe('docker-user')
expect(config.dockerHub?.token).toBe('docker-token')
expect(config.dockerHub?.registry).toBe('https://registry.example.com')
})
it('should not include Docker Hub when credentials are empty', () => {
const dockerHub = { username: '', token: '' }
const config = generateJobConfig(validOrder, 'password', dockerHub)
expect(config.dockerHub).toBeUndefined()
})
it('should include Gitea credentials when provided', () => {
const gitea = {
registry: 'https://code.letsbe.solutions',
username: 'gitea-user',
token: 'gitea-token',
}
const config = generateJobConfig(validOrder, 'password', undefined, gitea)
expect(config.gitea).toBeDefined()
expect(config.gitea?.registry).toBe('https://code.letsbe.solutions')
expect(config.gitea?.username).toBe('gitea-user')
expect(config.gitea?.token).toBe('gitea-token')
})
it('should not include Gitea when credentials are incomplete', () => {
const gitea = { registry: 'https://code.letsbe.solutions', username: '', token: '' }
const config = generateJobConfig(validOrder, 'password', undefined, gitea)
expect(config.gitea).toBeUndefined()
})
})
describe('decryptPassword', () => {
it('should use new credential service decrypt by default', () => {
const result = decryptPassword('encrypted-value')
expect(credentialService.decrypt).toHaveBeenCalledWith('encrypted-value')
expect(result).toBe('decrypted-password')
})
it('should fall back to legacy decrypt when new format fails', () => {
vi.mocked(credentialService.decrypt).mockImplementation(() => {
throw new Error('Invalid format')
})
const result = decryptPassword('legacy-encrypted-value')
expect(credentialService.decryptLegacy).toHaveBeenCalledWith('legacy-encrypted-value')
expect(result).toBe('legacy-password')
})
})
describe('generateRunnerToken', () => {
it('should generate a 64-character hex string', () => {
const token = generateRunnerToken()
expect(token).toHaveLength(64)
expect(token).toMatch(/^[0-9a-f]+$/)
})
it('should generate unique tokens', () => {
const token1 = generateRunnerToken()
const token2 = generateRunnerToken()
expect(token1).not.toBe(token2)
})
})
describe('hashRunnerToken', () => {
it('should return SHA-256 hash', () => {
const token = 'test-token'
const expectedHash = crypto.createHash('sha256').update(token).digest('hex')
const hash = hashRunnerToken(token)
expect(hash).toBe(expectedHash)
expect(hash).toHaveLength(64)
})
it('should produce consistent hashes', () => {
const token = 'consistent-token'
expect(hashRunnerToken(token)).toBe(hashRunnerToken(token))
})
})
describe('verifyRunnerToken', () => {
it('should return true for matching token', () => {
const token = 'valid-token'
const hash = hashRunnerToken(token)
expect(verifyRunnerToken(token, hash)).toBe(true)
})
it('should return false for non-matching token', () => {
const hash = hashRunnerToken('correct-token')
expect(verifyRunnerToken('wrong-token', hash)).toBe(false)
})
it('should use timing-safe comparison', () => {
// Verify it uses crypto.timingSafeEqual by checking it handles
// matching tokens correctly (if it wasn't timing-safe, this would still work)
const token = generateRunnerToken()
const hash = hashRunnerToken(token)
expect(verifyRunnerToken(token, hash)).toBe(true)
})
})
})

View File

@ -0,0 +1,456 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import { DnsRecordStatus, OrderStatus, LogLevel } from '@prisma/client'
// Mock the dns module
vi.mock('dns', () => {
const mockResolve4 = vi.fn()
return {
default: {
resolve4: mockResolve4,
},
resolve4: mockResolve4,
}
})
// Import after mocking
import dns from 'dns'
import {
getSubdomainsForTools,
verifyDns,
runDnsVerification,
skipDnsVerification,
getDnsStatus,
} from '@/lib/services/dns-service'
// Helper to make dns.resolve4 work as expected with promisify
function mockDnsResolve(mapping: Record<string, string[] | null>) {
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
const result = mapping[domain]
if (result === null || result === undefined) {
callback(new Error('ENOTFOUND'))
} else {
callback(null, result)
}
})
}
describe('DNS Service', () => {
beforeEach(() => {
resetPrismaMock()
vi.clearAllMocks()
})
describe('getSubdomainsForTools', () => {
it('should return empty array for core tools with no subdomains', () => {
const subdomains = getSubdomainsForTools(['core', 'portainer', 'orchestrator'])
expect(subdomains).toEqual([])
})
it('should return correct subdomains for poste', () => {
const subdomains = getSubdomainsForTools(['poste'])
expect(subdomains).toContain('mail')
})
it('should return correct subdomains for chatwoot', () => {
const subdomains = getSubdomainsForTools(['chatwoot'])
expect(subdomains).toContain('support')
expect(subdomains).toContain('helpdesk')
})
it('should return correct subdomains for nextcloud', () => {
const subdomains = getSubdomainsForTools(['nextcloud'])
expect(subdomains).toContain('cloud')
expect(subdomains).toContain('collabora')
expect(subdomains).toContain('whiteboard')
})
it('should deduplicate subdomains across tools', () => {
const subdomains = getSubdomainsForTools(['nextcloud', 'nextcloud'])
// Should not have duplicates
const uniqueSubdomains = [...new Set(subdomains)]
expect(subdomains.length).toBe(uniqueSubdomains.length)
})
it('should combine subdomains from multiple tools', () => {
const subdomains = getSubdomainsForTools(['poste', 'keycloak', 'n8n'])
expect(subdomains).toContain('mail')
expect(subdomains).toContain('auth')
expect(subdomains).toContain('n8n')
})
it('should return sorted subdomains', () => {
const subdomains = getSubdomainsForTools(['nextcloud', 'poste', 'keycloak'])
const sorted = [...subdomains].sort()
expect(subdomains).toEqual(sorted)
})
it('should handle unknown tool names gracefully', () => {
const subdomains = getSubdomainsForTools(['unknown-tool', 'poste'])
// Should still include known tool subdomains
expect(subdomains).toContain('mail')
})
it('should be case-insensitive for tool names', () => {
const subdomains = getSubdomainsForTools(['POSTE', 'Keycloak'])
expect(subdomains).toContain('mail')
expect(subdomains).toContain('auth')
})
it('should return correct subdomains for minio', () => {
const subdomains = getSubdomainsForTools(['minio'])
expect(subdomains).toContain('minio')
expect(subdomains).toContain('s3')
})
it('should return correct subdomains for calcom', () => {
const subdomains = getSubdomainsForTools(['calcom'])
expect(subdomains).toContain('bookings')
})
it('should return empty for tools using root domain (ghost, wordpress)', () => {
const subdomains = getSubdomainsForTools(['ghost', 'wordpress'])
expect(subdomains).toEqual([])
})
})
describe('verifyDns', () => {
it('should mark all subdomains as passed via wildcard when wildcard resolves', async () => {
// Mock wildcard check - any subdomain resolves to expected IP
mockDnsResolve({
// The wildcard test uses a random subdomain pattern
})
// Make all DNS lookups resolve to the target IP
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callback(null, ['10.0.0.1'])
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.wildcardPassed).toBe(true)
expect(result.allPassed).toBe(true)
expect(result.records.every((r) => r.status === DnsRecordStatus.SKIPPED)).toBe(true)
})
it('should check individual subdomains when wildcard fails', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// First call is wildcard check - fail it
callback(new Error('ENOTFOUND'))
} else if (domain === 'mail.example.com') {
callback(null, ['10.0.0.1'])
} else if (domain === 'auth.example.com') {
callback(null, ['10.0.0.1'])
} else {
callback(new Error('ENOTFOUND'))
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.wildcardPassed).toBe(false)
expect(result.allPassed).toBe(true)
expect(result.passedCount).toBe(2)
})
it('should detect IP mismatch', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// Wildcard check fails
callback(new Error('ENOTFOUND'))
} else {
// Subdomain resolves to wrong IP
callback(null, ['10.0.0.99'])
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste'])
expect(result.allPassed).toBe(false)
const mailRecord = result.records.find((r) => r.subdomain === 'mail')
expect(mailRecord?.status).toBe(DnsRecordStatus.MISMATCH)
expect(mailRecord?.resolvedIp).toBe('10.0.0.99')
})
it('should handle NOT_FOUND when subdomain has no A record', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// Wildcard check fails
callback(new Error('ENOTFOUND'))
} else {
callback(new Error('ENOTFOUND'))
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste'])
expect(result.allPassed).toBe(false)
const mailRecord = result.records.find((r) => r.subdomain === 'mail')
expect(mailRecord?.status).toBe(DnsRecordStatus.NOT_FOUND)
})
it('should return correct counts', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
callback(new Error('ENOTFOUND')) // wildcard
} else if (domain.startsWith('mail')) {
callback(null, ['10.0.0.1']) // mail passes
} else {
callback(new Error('ENOTFOUND')) // others fail
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.totalSubdomains).toBe(2)
expect(result.passedCount).toBe(1) // only mail passes
})
it('should return empty records for tools with no subdomains', async () => {
const result = await verifyDns('example.com', '10.0.0.1', ['core', 'portainer'])
expect(result.totalSubdomains).toBe(0)
expect(result.allPassed).toBe(true)
expect(result.records).toEqual([])
})
})
describe('runDnsVerification', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(runDnsVerification('invalid-id')).rejects.toThrow('Order not found')
})
it('should throw if server IP not configured', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
serverIp: null,
domain: 'test.com',
tools: ['poste'],
dnsVerification: null,
} as any)
await expect(runDnsVerification('order-123')).rejects.toThrow('Server IP not configured')
})
})
describe('skipDnsVerification', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(skipDnsVerification('invalid-id')).rejects.toThrow('Order not found')
})
it('should create new dns verification with manual override', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.dnsVerification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
orderId: 'order-123',
manualOverride: true,
allPassed: true,
verifiedAt: expect.any(Date),
}),
})
})
it('should update existing dns verification when one exists', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: {
id: 'dns-456',
},
} as any)
prismaMock.dnsVerification.update.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.dnsVerification.update).toHaveBeenCalledWith({
where: { id: 'dns-456' },
data: expect.objectContaining({
manualOverride: true,
allPassed: true,
}),
})
})
it('should update order status to DNS_READY when currently DNS_PENDING', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
dnsVerifiedAt: expect.any(Date),
}),
})
})
it('should update order status to DNS_READY when currently SERVER_READY', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.SERVER_READY,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
}),
})
})
it('should not update order status when not in DNS_PENDING or SERVER_READY', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.FULFILLED,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).not.toHaveBeenCalled()
})
it('should log the manual override', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.provisioningLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
orderId: 'order-123',
level: LogLevel.WARN,
message: 'DNS verification skipped via manual override',
step: 'dns',
}),
})
})
})
describe('getDnsStatus', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(getDnsStatus('invalid-id')).rejects.toThrow('Order not found')
})
it('should return status with null verification when none exists', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
domain: 'test.letsbe.cloud',
serverIp: '10.0.0.1',
status: OrderStatus.DNS_PENDING,
tools: ['poste'],
dnsVerification: null,
} as any)
const result = await getDnsStatus('order-123')
expect(result.domain).toBe('test.letsbe.cloud')
expect(result.serverIp).toBe('10.0.0.1')
expect(result.verification).toBeNull()
expect(result.requiredSubdomains).toContain('mail')
})
it('should return status with verification data when it exists', async () => {
const lastChecked = new Date()
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
domain: 'test.letsbe.cloud',
serverIp: '10.0.0.1',
status: OrderStatus.DNS_READY,
tools: ['poste'],
dnsVerification: {
id: 'dns-456',
wildcardPassed: false,
manualOverride: false,
allPassed: true,
totalSubdomains: 1,
passedCount: 1,
lastCheckedAt: lastChecked,
verifiedAt: lastChecked,
records: [
{
subdomain: 'mail',
fullDomain: 'mail.test.letsbe.cloud',
expectedIp: '10.0.0.1',
resolvedIp: '10.0.0.1',
status: DnsRecordStatus.VERIFIED,
},
],
},
} as any)
const result = await getDnsStatus('order-123')
expect(result.verification).not.toBeNull()
expect(result.verification?.allPassed).toBe(true)
expect(result.verification?.records).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,233 @@
import { describe, it, expect } from 'vitest'
import { StaffRole } from '@prisma/client'
import {
hasPermission,
hasAllPermissions,
hasAnyPermission,
getPermissions,
getRoleDisplayName,
getRoleDescription,
canManageRole,
canDeleteRole,
getAssignableRoles,
} from '@/lib/services/permission-service'
describe('Permission Service', () => {
describe('hasPermission', () => {
it('should grant OWNER all permissions', () => {
expect(hasPermission('OWNER' as StaffRole, 'dashboard:view')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'staff:delete')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'settings:edit')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'enterprise:manage')).toBe(true)
})
it('should grant ADMIN most permissions but not staff:delete', () => {
expect(hasPermission('ADMIN' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'orders:create')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'staff:manage')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'settings:edit')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'staff:delete')).toBe(false)
})
it('should grant MANAGER operational permissions but not staff or settings', () => {
expect(hasPermission('MANAGER' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'orders:create')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'orders:provision')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'servers:power')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'enterprise:manage')).toBe(true)
// Should NOT have staff or settings access
expect(hasPermission('MANAGER' as StaffRole, 'staff:view')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'staff:invite')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'settings:view')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'settings:edit')).toBe(false)
// Should NOT have delete permissions
expect(hasPermission('MANAGER' as StaffRole, 'orders:delete')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'customers:delete')).toBe(false)
})
it('should grant SUPPORT only view permissions', () => {
expect(hasPermission('SUPPORT' as StaffRole, 'dashboard:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'customers:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'servers:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'enterprise:view')).toBe(true)
// Should NOT have any action permissions
expect(hasPermission('SUPPORT' as StaffRole, 'orders:create')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'orders:edit')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'servers:power')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'staff:view')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'settings:view')).toBe(false)
})
it('should return false for unknown role', () => {
expect(hasPermission('UNKNOWN' as StaffRole, 'dashboard:view')).toBe(false)
})
})
describe('hasAllPermissions', () => {
it('should return true when role has all listed permissions', () => {
expect(
hasAllPermissions('OWNER' as StaffRole, ['orders:view', 'orders:create', 'staff:delete'])
).toBe(true)
})
it('should return false when role lacks any permission', () => {
expect(
hasAllPermissions('SUPPORT' as StaffRole, ['orders:view', 'orders:create'])
).toBe(false)
})
it('should return true for empty permissions list', () => {
expect(hasAllPermissions('SUPPORT' as StaffRole, [])).toBe(true)
})
})
describe('hasAnyPermission', () => {
it('should return true when role has at least one permission', () => {
expect(
hasAnyPermission('SUPPORT' as StaffRole, ['orders:create', 'orders:view'])
).toBe(true)
})
it('should return false when role has none of the permissions', () => {
expect(
hasAnyPermission('SUPPORT' as StaffRole, ['staff:delete', 'settings:edit'])
).toBe(false)
})
it('should return false for empty permissions list', () => {
expect(hasAnyPermission('OWNER' as StaffRole, [])).toBe(false)
})
})
describe('getPermissions', () => {
it('should return all permissions for OWNER', () => {
const perms = getPermissions('OWNER' as StaffRole)
expect(perms).toContain('staff:delete')
expect(perms).toContain('settings:edit')
expect(perms).toContain('enterprise:manage')
expect(perms.length).toBeGreaterThan(15)
})
it('should return fewer permissions for SUPPORT than OWNER', () => {
const ownerPerms = getPermissions('OWNER' as StaffRole)
const supportPerms = getPermissions('SUPPORT' as StaffRole)
expect(supportPerms.length).toBeLessThan(ownerPerms.length)
})
it('should return empty array for unknown role', () => {
expect(getPermissions('UNKNOWN' as StaffRole)).toEqual([])
})
})
describe('getRoleDisplayName', () => {
it('should return correct display names', () => {
expect(getRoleDisplayName('OWNER' as StaffRole)).toBe('Owner')
expect(getRoleDisplayName('ADMIN' as StaffRole)).toBe('Administrator')
expect(getRoleDisplayName('MANAGER' as StaffRole)).toBe('Manager')
expect(getRoleDisplayName('SUPPORT' as StaffRole)).toBe('Support')
})
it('should return raw role for unknown role', () => {
expect(getRoleDisplayName('UNKNOWN' as StaffRole)).toBe('UNKNOWN')
})
})
describe('getRoleDescription', () => {
it('should return non-empty description for each role', () => {
expect(getRoleDescription('OWNER' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('ADMIN' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('MANAGER' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('SUPPORT' as StaffRole).length).toBeGreaterThan(0)
})
it('should mention key restrictions in descriptions', () => {
expect(getRoleDescription('OWNER' as StaffRole)).toContain('Cannot be deleted')
expect(getRoleDescription('ADMIN' as StaffRole)).toContain('not delete')
expect(getRoleDescription('SUPPORT' as StaffRole)).toContain('View-only')
})
it('should return empty string for unknown role', () => {
expect(getRoleDescription('UNKNOWN' as StaffRole)).toBe('')
})
})
describe('canManageRole', () => {
it('should allow OWNER to manage any role', () => {
expect(canManageRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(true)
})
it('should allow ADMIN to manage MANAGER and SUPPORT', () => {
expect(canManageRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canManageRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
})
it('should deny ADMIN managing ADMIN or OWNER', () => {
expect(canManageRole('ADMIN' as StaffRole, 'ADMIN' as StaffRole)).toBe(false)
expect(canManageRole('ADMIN' as StaffRole, 'OWNER' as StaffRole)).toBe(false)
})
it('should deny MANAGER managing anyone', () => {
expect(canManageRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
expect(canManageRole('MANAGER' as StaffRole, 'MANAGER' as StaffRole)).toBe(false)
})
it('should deny SUPPORT managing anyone', () => {
expect(canManageRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
})
describe('canDeleteRole', () => {
it('should allow OWNER to delete ADMIN, MANAGER, SUPPORT', () => {
expect(canDeleteRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true)
expect(canDeleteRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canDeleteRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
})
it('should deny OWNER deleting another OWNER', () => {
expect(canDeleteRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(false)
})
it('should deny ADMIN deleting anyone', () => {
expect(canDeleteRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(false)
expect(canDeleteRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
it('should deny MANAGER deleting anyone', () => {
expect(canDeleteRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
it('should deny SUPPORT deleting anyone', () => {
expect(canDeleteRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
})
describe('getAssignableRoles', () => {
it('should return ADMIN, MANAGER, SUPPORT for OWNER', () => {
const roles = getAssignableRoles('OWNER' as StaffRole)
expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT'])
expect(roles).not.toContain('OWNER')
})
it('should return ADMIN, MANAGER, SUPPORT for ADMIN', () => {
const roles = getAssignableRoles('ADMIN' as StaffRole)
expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT'])
})
it('should return empty array for MANAGER', () => {
expect(getAssignableRoles('MANAGER' as StaffRole)).toEqual([])
})
it('should return empty array for SUPPORT', () => {
expect(getAssignableRoles('SUPPORT' as StaffRole)).toEqual([])
})
})
})

View File

@ -0,0 +1,296 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
// Mock the email service
vi.mock('@/lib/services/email-service', () => ({
emailService: {
isConfigured: vi.fn().mockResolvedValue(false),
sendEmail: vi.fn().mockResolvedValue({ success: true }),
},
}))
import { securityVerificationService } from '@/lib/services/security-verification-service'
describe('SecurityVerificationService', () => {
beforeEach(() => {
resetPrismaMock()
vi.clearAllMocks()
// Suppress console.log from email fallback logging
vi.spyOn(console, 'log').mockImplementation(() => {})
})
describe('requestVerificationCode', () => {
const mockClient = {
id: 'client-123',
name: 'Test Client',
contactEmail: 'admin@testclient.com',
}
const mockServer = {
id: 'server-456',
nickname: 'Production Server',
netcupServerId: 'ns-789',
}
beforeEach(() => {
prismaMock.enterpriseClient.findUnique.mockResolvedValue(mockClient as any)
prismaMock.enterpriseServer.findFirst.mockResolvedValue(mockServer as any)
prismaMock.securityVerificationCode.updateMany.mockResolvedValue({ count: 0 } as any)
prismaMock.securityVerificationCode.create.mockResolvedValue({} as any)
})
it('should throw when client not found', async () => {
prismaMock.enterpriseClient.findUnique.mockResolvedValue(null)
await expect(
securityVerificationService.requestVerificationCode('bad-id', 'server-456', 'WIPE')
).rejects.toThrow('Client not found')
})
it('should throw when server not found', async () => {
prismaMock.enterpriseServer.findFirst.mockResolvedValue(null)
await expect(
securityVerificationService.requestVerificationCode('client-123', 'bad-id', 'WIPE')
).rejects.toThrow('Server not found or does not belong to this client')
})
it('should invalidate existing unused codes before creating new one', async () => {
const result = await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
expect(prismaMock.securityVerificationCode.updateMany).toHaveBeenCalledWith({
where: {
clientId: 'client-123',
targetServerId: 'server-456',
action: 'WIPE',
usedAt: null,
},
data: {
usedAt: expect.any(Date),
},
})
})
it('should create a new verification code', async () => {
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'REINSTALL'
)
expect(prismaMock.securityVerificationCode.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clientId: 'client-123',
action: 'REINSTALL',
targetServerId: 'server-456',
code: expect.any(String),
expiresAt: expect.any(Date),
}),
})
})
it('should generate 8-digit code', async () => {
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0]
const code = createCall.data.code as string
expect(code).toHaveLength(8)
expect(code).toMatch(/^\d{8}$/)
})
it('should set expiry 15 minutes in the future', async () => {
const before = Date.now()
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0]
const expiresAt = createCall.data.expiresAt as Date
const after = Date.now()
const fifteenMinMs = 15 * 60 * 1000
// expiresAt should be approximately 15 minutes from now
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + fifteenMinMs - 1000)
expect(expiresAt.getTime()).toBeLessThanOrEqual(after + fifteenMinMs + 1000)
})
it('should return masked email and expiry', async () => {
const result = await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
expect(result.expiresAt).toBeInstanceOf(Date)
// Email should be masked (a***n@testclient.com pattern)
expect(result.email).toContain('@testclient.com')
expect(result.email).toContain('*')
})
})
describe('verifyCode', () => {
it('should return invalid when no active code exists', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue(null)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(false)
expect(result.errorMessage).toBe('Invalid or expired verification code')
})
it('should return invalid when max attempts exceeded', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 5,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(false)
expect(result.errorMessage).toContain('Too many failed attempts')
// Should invalidate the code
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { usedAt: expect.any(Date) },
})
})
it('should increment attempt counter on wrong code', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 2,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
expect(result.valid).toBe(false)
expect(result.errorMessage).toContain('attempt(s) remaining')
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { attempts: { increment: 1 } },
})
})
it('should show remaining attempts in error message', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 3,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
// 5 max - 3 current - 1 this attempt = 1 remaining
expect(result.errorMessage).toContain('1 attempt(s) remaining')
})
it('should show too many attempts when at last attempt', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 4,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
expect(result.errorMessage).toContain('Too many failed attempts')
})
it('should return valid with action and serverId on correct code', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 0,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(true)
expect(result.action).toBe('WIPE')
expect(result.serverId).toBe('server-456')
})
it('should mark code as used after successful verification', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 0,
clientId: 'client-123',
action: 'REINSTALL',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
await securityVerificationService.verifyCode('client-123', '12345678')
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { usedAt: expect.any(Date) },
})
})
})
describe('cleanupExpiredCodes', () => {
it('should delete expired and used codes older than 24h', async () => {
prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 5 } as any)
const result = await securityVerificationService.cleanupExpiredCodes()
expect(result).toBe(5)
expect(prismaMock.securityVerificationCode.deleteMany).toHaveBeenCalledWith({
where: {
OR: [
{ expiresAt: { lt: expect.any(Date) } },
{ usedAt: { not: null } },
],
createdAt: { lt: expect.any(Date) },
},
})
})
it('should return 0 when no codes to clean up', async () => {
prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 0 } as any)
const result = await securityVerificationService.cleanupExpiredCodes()
expect(result).toBe(0)
})
})
})

View File

@ -1,3 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { handlers } from '@/lib/auth' import { handlers } from '@/lib/auth'
import { loginLimiter } from '@/lib/rate-limit'
export const { GET, POST } = handlers export const GET = handlers.GET
/**
* POST handler with rate limiting for login attempts.
* NextAuth credential auth goes through POST /api/auth/callback/credentials
*/
export async function POST(request: NextRequest) {
// Apply rate limiting to credential login attempts
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const url = request.nextUrl.pathname
if (url.includes('/callback/credentials')) {
const result = loginLimiter.check(`login:${ip}`)
if (result.limited) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(result.retryAfter || 60),
},
}
)
}
}
return handlers.POST(request)
}

View File

@ -17,11 +17,14 @@ import { statsCollectionService } from '@/lib/services/stats-collection-service'
* } * }
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Verify cron secret (for security in production) // Verify cron secret
const cronSecret = process.env.CRON_SECRET const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('[Cron] CRON_SECRET environment variable is not set')
return NextResponse.json({ error: 'Cron not configured' }, { status: 500 })
}
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }

View File

@ -24,11 +24,14 @@ import { containerHealthService } from '@/lib/services/container-health-service'
* 3. Checks container health and detects crashes/OOM kills * 3. Checks container health and detects crashes/OOM kills
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Verify cron secret (for security in production) // Verify cron secret
const cronSecret = process.env.CRON_SECRET const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('[Cron] CRON_SECRET environment variable is not set')
return NextResponse.json({ error: 'Cron not configured' }, { status: 500 })
}
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth' import { requireStaffPermission } from '@/lib/auth-helpers'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { JobStatus, OrderStatus } from '@prisma/client' import { JobStatus, OrderStatus } from '@prisma/client'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
@ -28,11 +28,8 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await auth() // Require orders:provision permission (OWNER, ADMIN, MANAGER)
await requireStaffPermission('orders:provision')
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await params const { id: orderId } = await params
@ -297,7 +294,10 @@ export async function POST(
containerName: spawnResult.containerName, containerName: spawnResult.containerName,
serverConnectionId: serverConnection.id, serverConnectionId: serverConnection.id,
}) })
} catch (error) { } catch (error: any) {
if (error?.status) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error('Error triggering provisioning:', error) console.error('Error triggering provisioning:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to trigger provisioning' }, { error: 'Failed to trigger provisioning' },

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto' import crypto from 'crypto'
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { requireStaffPermission } from '@/lib/auth-helpers'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { OrderStatus } from '@prisma/client' import { OrderStatus } from '@prisma/client'
import { processAutomation } from '@/lib/services/automation-worker' import { processAutomation } from '@/lib/services/automation-worker'
@ -321,11 +322,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await auth() // Require orders:delete permission (OWNER, ADMIN only)
await requireStaffPermission('orders:delete')
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await params const { id: orderId } = await params
@ -371,7 +369,10 @@ export async function DELETE(
success: true, success: true,
message: `Order ${orderId} and all related records deleted`, message: `Order ${orderId} and all related records deleted`,
}) })
} catch (error) { } catch (error: any) {
if (error?.status) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error('Error deleting order:', error) console.error('Error deleting order:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to delete order' }, { error: 'Failed to delete order' },

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth' import { requireStaffPermission } from '@/lib/auth-helpers'
interface RouteContext { interface RouteContext {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -22,19 +22,65 @@ const ALLOWED_COMMAND_TYPES = [
'DOCKER_COMPOSE_RESTART', // Restart stack 'DOCKER_COMPOSE_RESTART', // Restart stack
'GET_LOGS', // Get container logs 'GET_LOGS', // Get container logs
'GET_STATUS', // Get system status 'GET_STATUS', // Get system status
'DOCKER_RELOAD', // Reload Docker stack
'FILE_WRITE', // Write a file
'ENV_UPDATE', // Update .env file
'ENV_INSPECT', // Inspect .env file
'FILE_INSPECT', // Inspect a file
] ]
function validatePayload(type: string, payload: Record<string, unknown>): { valid: boolean; error?: string } {
switch (type) {
case 'DOCKER_RELOAD': {
const composePath = payload.compose_path as string
if (!composePath || !composePath.startsWith('/opt/letsbe/stacks/')) {
return { valid: false, error: 'compose_path must start with /opt/letsbe/stacks/' }
}
if (composePath.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' }
}
return { valid: true }
}
case 'SHELL': {
return { valid: false, error: 'SHELL commands are not allowed via remote command API' }
}
case 'FILE_WRITE': {
const path = payload.path as string
if (!path || !path.startsWith('/opt/letsbe/')) {
return { valid: false, error: 'path must start with /opt/letsbe/' }
}
if (path.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' }
}
return { valid: true }
}
case 'ENV_UPDATE': {
const envPath = payload.path as string
if (!envPath || !envPath.startsWith('/opt/letsbe/env/')) {
return { valid: false, error: 'path must start with /opt/letsbe/env/' }
}
if (envPath.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' }
}
return { valid: true }
}
case 'ECHO':
case 'ENV_INSPECT':
case 'FILE_INSPECT':
return { valid: true }
default:
return { valid: false, error: `Unknown command type: ${type}` }
}
}
/** /**
* GET /api/v1/admin/servers/[id]/command * GET /api/v1/admin/servers/[id]/command
* Get command history for a server * Get command history for a server
*/ */
export async function GET(request: NextRequest, context: RouteContext) { export async function GET(request: NextRequest, context: RouteContext) {
try { try {
// Authentication check // Authentication check - require servers:view permission
const session = await auth() await requireStaffPermission('servers:view')
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await context.params const { id: orderId } = await context.params
const searchParams = request.nextUrl.searchParams const searchParams = request.nextUrl.searchParams
@ -75,7 +121,10 @@ export async function GET(request: NextRequest, context: RouteContext) {
hasMore: offset + commands.length < total, hasMore: offset + commands.length < total,
}, },
}) })
} catch (error) { } catch (error: any) {
if (error?.status) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error('Get commands error:', error) console.error('Get commands error:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
@ -90,11 +139,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
*/ */
export async function POST(request: NextRequest, context: RouteContext) { export async function POST(request: NextRequest, context: RouteContext) {
try { try {
// Authentication check // Authentication check - require servers:power permission for sending commands
const session = await auth() const session = await requireStaffPermission('servers:power')
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await context.params const { id: orderId } = await context.params
const body: CommandRequest = await request.json() const body: CommandRequest = await request.json()
@ -116,6 +162,15 @@ export async function POST(request: NextRequest, context: RouteContext) {
) )
} }
// Validate payload based on command type
const validation = validatePayload(body.type, body.payload || {})
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error },
{ status: 400 }
)
}
// Find server connection by order ID // Find server connection by order ID
const serverConnection = await prisma.serverConnection.findUnique({ const serverConnection = await prisma.serverConnection.findUnique({
where: { orderId }, where: { orderId },
@ -147,7 +202,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
serverConnectionId: serverConnection.id, serverConnectionId: serverConnection.id,
type: body.type, type: body.type,
payload: (body.payload || {}) as object, payload: (body.payload || {}) as object,
initiatedBy: session?.user?.email || 'unknown', initiatedBy: session.user.email || 'unknown',
}, },
}) })
@ -166,7 +221,10 @@ export async function POST(request: NextRequest, context: RouteContext) {
lastHeartbeat: serverConnection.lastHeartbeat, lastHeartbeat: serverConnection.lastHeartbeat,
}, },
}) })
} catch (error) { } catch (error: any) {
if (error?.status) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error('Queue command error:', error) console.error('Queue command error:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { apiKeyService } from '@/lib/services/api-key-service'
interface CommandResultRequest { interface CommandResultRequest {
commandId: string commandId: string
@ -9,7 +10,7 @@ interface CommandResultRequest {
} }
/** /**
* Validate Hub API key from Authorization header * Validate Hub API key from Authorization header (uses hash-based lookup)
*/ */
async function validateHubApiKey(request: NextRequest) { async function validateHubApiKey(request: NextRequest) {
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
@ -20,11 +21,7 @@ async function validateHubApiKey(request: NextRequest) {
const hubApiKey = authHeader.replace('Bearer ', '') const hubApiKey = authHeader.replace('Bearer ', '')
const serverConnection = await prisma.serverConnection.findUnique({ return apiKeyService.findByApiKey(hubApiKey)
where: { hubApiKey },
})
return serverConnection
} }
/** /**

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { credentialService } from '@/lib/services/credential-service' import { credentialService } from '@/lib/services/credential-service'
import { apiKeyService } from '@/lib/services/api-key-service'
interface HeartbeatRequest { interface HeartbeatRequest {
agentVersion?: string agentVersion?: string
@ -20,7 +21,7 @@ interface HeartbeatRequest {
} }
/** /**
* Validate Hub API key from Authorization header * Validate Hub API key from Authorization header (uses hash-based lookup)
*/ */
async function validateHubApiKey(request: NextRequest) { async function validateHubApiKey(request: NextRequest) {
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
@ -31,20 +32,7 @@ async function validateHubApiKey(request: NextRequest) {
const hubApiKey = authHeader.replace('Bearer ', '') const hubApiKey = authHeader.replace('Bearer ', '')
const serverConnection = await prisma.serverConnection.findUnique({ return apiKeyService.findByApiKey(hubApiKey)
where: { hubApiKey },
include: {
order: {
select: {
id: true,
domain: true,
serverIp: true,
},
},
},
})
return serverConnection
} }
/** /**

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { randomBytes } from 'crypto' import { apiKeyService } from '@/lib/services/api-key-service'
interface RegisterRequest { interface RegisterRequest {
registrationToken: string registrationToken: string
@ -56,14 +56,16 @@ export async function POST(request: NextRequest) {
) )
} }
// Generate Hub API key for this server // Generate Hub API key for this server (store hash, return plaintext once)
const hubApiKey = `hk_${randomBytes(32).toString('hex')}` const hubApiKey = apiKeyService.generateKey()
const hubApiKeyHash = apiKeyService.hashKey(hubApiKey)
// Update server connection with registration info // Update server connection with registration info
const updatedConnection = await prisma.serverConnection.update({ const updatedConnection = await prisma.serverConnection.update({
where: { id: serverConnection.id }, where: { id: serverConnection.id },
data: { data: {
hubApiKey, hubApiKey,
hubApiKeyHash,
orchestratorUrl: body.orchestratorUrl || null, orchestratorUrl: body.orchestratorUrl || null,
agentVersion: body.agentVersion || null, agentVersion: body.agentVersion || null,
status: 'REGISTERED', status: 'REGISTERED',

View File

@ -0,0 +1,211 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { stripeService } from '@/lib/services/stripe-service'
import {
OrderStatus,
AutomationMode,
SubscriptionStatus,
UserStatus,
} from '@prisma/client'
import { emailService } from '@/lib/services/email-service'
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
/**
* Generate a customer ID slug from company/user name
* Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$)
*/
function slugifyCustomer(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z]/g, '')
.substring(0, 32)
}
/**
* Generate a unique license key for the order
*/
function generateLicenseKey(): string {
const hex = randomBytes(16).toString('hex')
return `lb_inst_${hex}`
}
/**
* POST /api/v1/webhooks/stripe
*
* Handles Stripe webhook events. The primary event is checkout.session.completed,
* which creates a Customer, Order, and Subscription in the Hub database.
*
* Important: The raw body must be passed to constructEvent for signature verification.
* Next.js App Router provides the raw body via request.text().
*/
export async function POST(request: NextRequest) {
if (!stripeService.isConfigured()) {
console.error('Stripe webhook received but Stripe is not configured')
return NextResponse.json(
{ error: 'Stripe is not configured' },
{ status: 500 }
)
}
// Get the raw body as text for signature verification
const rawBody = await request.text()
// Get the Stripe signature header
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing stripe-signature header' },
{ status: 400 }
)
}
// Verify webhook signature and construct event
let event
try {
event = stripeService.constructWebhookEvent(rawBody, signature)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error(`Stripe webhook signature verification failed: ${message}`)
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 }
)
}
// Handle the event
try {
switch (event.type) {
case 'checkout.session.completed': {
await handleCheckoutSessionCompleted(event.data.object)
break
}
default:
// Acknowledge other events without processing
console.log(`Unhandled Stripe event type: ${event.type}`)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error(`Error handling Stripe event ${event.type}: ${message}`)
return NextResponse.json(
{ error: `Failed to handle event: ${message}` },
{ status: 500 }
)
}
return NextResponse.json({ received: true })
}
/**
* Handle checkout.session.completed event
*
* 1. Find or create User by email
* 2. Create Subscription with Stripe IDs
* 3. Create Order with PAYMENT_CONFIRMED status
*/
async function handleCheckoutSessionCompleted(
session: Record<string, unknown>
) {
const customerEmail = session.customer_email as string | undefined
const customerName = (session.metadata as Record<string, string>)?.customer_name
const customerDomain = (session.metadata as Record<string, string>)?.domain
const stripeCustomerId = session.customer as string | undefined
const stripeSubscriptionId = session.subscription as string | undefined
if (!customerEmail) {
throw new Error('checkout.session.completed missing customer_email')
}
// Extract plan info from session metadata
const planMapping = stripeService.extractPlanFromSession(
session as unknown as import('stripe').Stripe.Checkout.Session
)
if (!planMapping) {
throw new Error(
'Could not determine plan from checkout session. ' +
'Ensure metadata includes price_id or plan.'
)
}
// 1. Find or create User
let user = await prisma.user.findUnique({
where: { email: customerEmail },
})
if (!user) {
const randomPassword = randomBytes(16).toString('hex')
const passwordHash = await bcrypt.hash(randomPassword, 10)
user = await prisma.user.create({
data: {
email: customerEmail,
name: customerName || null,
passwordHash,
status: UserStatus.ACTIVE,
},
})
}
// 2. Create Subscription
await prisma.subscription.create({
data: {
userId: user.id,
plan: planMapping.plan,
tier: planMapping.tier,
tokenLimit: planMapping.tokenLimit,
status: SubscriptionStatus.ACTIVE,
stripeCustomerId: stripeCustomerId || null,
stripeSubscriptionId: stripeSubscriptionId || null,
},
})
// 3. Create Order
const displayName = customerName || user.company || user.name || 'customer'
const customerSlug = slugifyCustomer(displayName) || 'customer'
const domain = customerDomain || `${customerSlug}.letsbe.cloud`
const licenseKey = generateLicenseKey()
await prisma.order.create({
data: {
userId: user.id,
domain,
tier: planMapping.tier,
tools: planMapping.tools,
status: OrderStatus.PAYMENT_CONFIRMED,
automationMode: AutomationMode.AUTO,
source: 'stripe',
customer: customerSlug,
companyName: displayName,
licenseKey,
configJson: {
tools: planMapping.tools,
tier: planMapping.tier,
domain,
stripeSessionId: session.id,
stripeCustomerId,
stripeSubscriptionId,
},
},
})
console.log(
`Stripe checkout completed: created order for ${customerEmail} ` +
`(plan: ${planMapping.plan}, domain: ${domain})`
)
// 4. Send welcome email (non-blocking - don't fail webhook on email error)
try {
const emailResult = await emailService.sendWelcomeEmail({
to: customerEmail,
customerName: customerName || user.name || '',
plan: planMapping.plan,
domain,
})
if (!emailResult.success) {
console.warn(`Welcome email failed for ${customerEmail}: ${emailResult.error}`)
}
} catch (emailErr) {
console.warn('Failed to send welcome email:', emailErr)
}
}

View File

@ -6,9 +6,8 @@ import { prisma } from './prisma'
import { StaffRole, StaffStatus } from '@prisma/client' import { StaffRole, StaffStatus } from '@prisma/client'
import { totpService } from './services/totp-service' import { totpService } from './services/totp-service'
// Pending 2FA sessions (in-memory, 5-minute TTL) // Pending 2FA sessions - stored in DB for multi-instance support
// In production, consider using Redis for multi-instance support interface Pending2FASessionData {
interface Pending2FASession {
userId: string userId: string
userType: 'customer' | 'staff' userType: 'customer' | 'staff'
email: string email: string
@ -24,42 +23,61 @@ interface Pending2FASession {
expiresAt: number expiresAt: number
} }
const pending2FASessions = new Map<string, Pending2FASession>()
// Clean up expired sessions every minute
setInterval(() => {
const now = Date.now()
for (const [token, session] of pending2FASessions) {
if (session.expiresAt < now) {
pending2FASessions.delete(token)
}
}
}, 60000)
/** /**
* Get a pending 2FA session by token * Get a pending 2FA session by token (from DB)
*/ */
export function getPending2FASession(token: string): Pending2FASession | null { export async function getPending2FASession(token: string): Promise<Pending2FASessionData | null> {
const session = pending2FASessions.get(token) const session = await prisma.pending2FASession.findUnique({
where: { token },
})
if (!session) return null if (!session) return null
if (session.expiresAt < Date.now()) { if (session.expiresAt < new Date()) {
pending2FASessions.delete(token) await prisma.pending2FASession.delete({ where: { token } }).catch(() => {})
return null return null
} }
return session return {
userId: session.userId,
userType: session.userType as 'customer' | 'staff',
email: session.email,
name: session.name,
role: session.role as StaffRole | undefined,
company: session.company,
subscription: session.subscription as Pending2FASessionData['subscription'],
expiresAt: session.expiresAt.getTime(),
}
}
/**
* Store a pending 2FA session in DB
*/
async function storePending2FASession(token: string, data: Pending2FASessionData): Promise<void> {
await prisma.pending2FASession.create({
data: {
token,
userId: data.userId,
userType: data.userType,
email: data.email,
name: data.name,
role: data.role ?? null,
company: data.company ?? null,
subscription: data.subscription ?? undefined,
expiresAt: new Date(data.expiresAt),
},
})
} }
/** /**
* Clear a pending 2FA session after successful verification * Clear a pending 2FA session after successful verification
*/ */
export function clearPending2FASession(token: string): void { export async function clearPending2FASession(token: string): Promise<void> {
pending2FASessions.delete(token) await prisma.pending2FASession.delete({ where: { token } }).catch(() => {})
} }
export const { handlers, auth, signIn, signOut } = NextAuth({ export const { handlers, auth, signIn, signOut } = NextAuth({
session: { session: {
strategy: 'jwt', strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days maxAge: 7 * 24 * 60 * 60, // 7 days
updateAge: 24 * 60 * 60, // Refresh token every 24 hours
}, },
pages: { pages: {
signIn: '/login', signIn: '/login',
@ -81,7 +99,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const pendingToken = credentials.pendingToken as string const pendingToken = credentials.pendingToken as string
const twoFactorToken = credentials.twoFactorToken as string const twoFactorToken = credentials.twoFactorToken as string
const pending = getPending2FASession(pendingToken) const pending = await getPending2FASession(pendingToken)
if (!pending) { if (!pending) {
throw new Error('Session expired. Please log in again.') throw new Error('Session expired. Please log in again.')
} }
@ -139,7 +157,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
} }
// Clear pending session // Clear pending session
clearPending2FASession(pendingToken) await clearPending2FASession(pendingToken)
// Return the user data from the pending session // Return the user data from the pending session
if (pending.userType === 'staff') { if (pending.userType === 'staff') {
@ -198,10 +216,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// Check if 2FA is enabled // Check if 2FA is enabled
if (user.twoFactorEnabled) { if (user.twoFactorEnabled) {
// Create pending 2FA session // Create pending 2FA session (stored in DB)
const pendingToken = crypto.randomBytes(32).toString('hex') const pendingToken = crypto.randomBytes(32).toString('hex')
const subscription = user.subscriptions[0] const subscription = user.subscriptions[0]
pending2FASessions.set(pendingToken, { await storePending2FASession(pendingToken, {
userId: user.id, userId: user.id,
userType: 'customer', userType: 'customer',
email: user.email, email: user.email,
@ -247,9 +265,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// Check if 2FA is enabled // Check if 2FA is enabled
if (staff.twoFactorEnabled) { if (staff.twoFactorEnabled) {
// Create pending 2FA session // Create pending 2FA session (stored in DB)
const pendingToken = crypto.randomBytes(32).toString('hex') const pendingToken = crypto.randomBytes(32).toString('hex')
pending2FASessions.set(pendingToken, { await storePending2FASession(pendingToken, {
userId: staff.id, userId: staff.id,
userType: 'staff', userType: 'staff',
email: staff.email, email: staff.email,

79
src/lib/rate-limit.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* Simple in-memory rate limiter for API endpoints.
* Uses a sliding window approach with configurable limits.
*
* For production multi-instance deployments, consider Redis-backed rate limiting.
*/
interface RateLimitEntry {
count: number
resetAt: number
}
interface RateLimiterOptions {
/** Maximum number of requests in the window */
maxRequests: number
/** Window duration in seconds */
windowSeconds: number
}
class RateLimiter {
private store = new Map<string, RateLimitEntry>()
private readonly maxRequests: number
private readonly windowMs: number
constructor(options: RateLimiterOptions) {
this.maxRequests = options.maxRequests
this.windowMs = options.windowSeconds * 1000
// Cleanup expired entries every 60 seconds
setInterval(() => {
const now = Date.now()
for (const [key, entry] of this.store) {
if (entry.resetAt < now) {
this.store.delete(key)
}
}
}, 60000)
}
/**
* Check if a request should be rate limited.
* Returns { limited: false } if allowed, or { limited: true, retryAfter } if blocked.
*/
check(key: string): { limited: boolean; retryAfter?: number; remaining?: number } {
const now = Date.now()
const entry = this.store.get(key)
if (!entry || entry.resetAt < now) {
// First request or window expired
this.store.set(key, { count: 1, resetAt: now + this.windowMs })
return { limited: false, remaining: this.maxRequests - 1 }
}
if (entry.count >= this.maxRequests) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
return { limited: true, retryAfter }
}
entry.count++
return { limited: false, remaining: this.maxRequests - entry.count }
}
}
// Pre-configured rate limiters for common use cases
/** Login attempts: 5 per 15 minutes per IP */
export const loginLimiter = new RateLimiter({ maxRequests: 5, windowSeconds: 900 })
/** 2FA verification attempts: 5 per 5 minutes per token */
export const twoFactorLimiter = new RateLimiter({ maxRequests: 5, windowSeconds: 300 })
/** Registration: 3 per hour per IP */
export const registrationLimiter = new RateLimiter({ maxRequests: 3, windowSeconds: 3600 })
/** General API: 100 per minute per IP */
export const apiLimiter = new RateLimiter({ maxRequests: 100, windowSeconds: 60 })
export { RateLimiter }
export type { RateLimiterOptions }

View File

@ -0,0 +1,82 @@
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
/**
* Service for managing Hub API keys with SHA-256 hashing.
* API keys are stored as hashes to prevent plaintext exposure in the database.
*/
class ApiKeyService {
private static instance: ApiKeyService
static getInstance(): ApiKeyService {
if (!ApiKeyService.instance) {
ApiKeyService.instance = new ApiKeyService()
}
return ApiKeyService.instance
}
/**
* Generate a new Hub API key with prefix
*/
generateKey(): string {
return `hk_${crypto.randomBytes(32).toString('hex')}`
}
/**
* Hash an API key using SHA-256
*/
hashKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex')
}
/**
* Find a server connection by API key (checks both plaintext and hash)
* During migration, we check both fields. Once all keys are migrated,
* the plaintext hubApiKey column can be removed.
*/
async findByApiKey(apiKey: string) {
const hash = this.hashKey(apiKey)
// First try hash lookup (preferred)
let connection = await prisma.serverConnection.findUnique({
where: { hubApiKeyHash: hash },
include: {
order: {
select: {
id: true,
domain: true,
serverIp: true,
},
},
},
})
if (connection) return connection
// Fallback to plaintext lookup during migration
connection = await prisma.serverConnection.findUnique({
where: { hubApiKey: apiKey },
include: {
order: {
select: {
id: true,
domain: true,
serverIp: true,
},
},
},
})
// If found via plaintext, migrate to hash
if (connection && !connection.hubApiKeyHash) {
await prisma.serverConnection.update({
where: { id: connection.id },
data: { hubApiKeyHash: hash },
})
}
return connection
}
}
export const apiKeyService = ApiKeyService.getInstance()

View File

@ -183,6 +183,84 @@ export class EmailService {
}) })
} }
/**
* Send a welcome email after Stripe checkout completion
*/
async sendWelcomeEmail(params: {
to: string
customerName: string
plan: string
domain: string
}): Promise<{ success: boolean; error?: string; messageId?: string }> {
const planDisplay = params.plan === 'PRO' ? 'Professional' : 'Starter'
return this.sendEmail({
to: params.to,
subject: `Welcome to LetsBe Cloud - Your ${planDisplay} plan is being set up`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; background: #ffffff;">
<div style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); padding: 32px 24px; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px; font-weight: 700;">Welcome to LetsBe Cloud</h1>
</div>
<div style="padding: 32px 24px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin-top: 0;">
Hi${params.customerName ? ` ${params.customerName}` : ''},
</p>
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
Thank you for choosing LetsBe Cloud! Your <strong>${planDisplay}</strong> plan has been activated
and we are now setting up your dedicated server.
</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 24px 0;">
<p style="color: #374151; font-size: 14px; margin: 0 0 8px 0;"><strong>Plan:</strong> ${planDisplay}</p>
<p style="color: #374151; font-size: 14px; margin: 0 0 8px 0;"><strong>Domain:</strong> ${params.domain}</p>
<p style="color: #374151; font-size: 14px; margin: 0;"><strong>Status:</strong> Provisioning in progress</p>
</div>
<h2 style="color: #111827; font-size: 18px; margin-top: 28px;">What happens next?</h2>
<ol style="color: #374151; font-size: 14px; line-height: 1.8; padding-left: 20px;">
<li>We provision your dedicated server with all your tools</li>
<li>SSL certificates are configured for every service</li>
<li>Single sign-on is set up across all your tools</li>
<li>You receive access credentials once everything is ready</li>
</ol>
<p style="color: #374151; font-size: 14px; line-height: 1.6;">
This process typically takes under 30 minutes. We will send you another email
once your server is ready with your login details.
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; margin-top: 24px;">
If you have any questions, reply to this email or contact us at
<a href="mailto:hello@letsbe.cloud" style="color: #2563eb;">hello@letsbe.cloud</a>.
</p>
</div>
<div style="padding: 20px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; text-align: center;">
<p style="color: #9ca3af; margin: 0; font-size: 12px;">
LetsBe Cloud &mdash; Your business tools, your server, fully managed.
</p>
</div>
</div>
`,
text: [
`Welcome to LetsBe Cloud!`,
``,
`Hi${params.customerName ? ` ${params.customerName}` : ''},`,
``,
`Thank you for choosing LetsBe Cloud! Your ${planDisplay} plan has been activated and we are now setting up your dedicated server.`,
``,
`Plan: ${planDisplay}`,
`Domain: ${params.domain}`,
`Status: Provisioning in progress`,
``,
`What happens next?`,
`1. We provision your dedicated server with all your tools`,
`2. SSL certificates are configured for every service`,
`3. Single sign-on is set up across all your tools`,
`4. You receive access credentials once everything is ready`,
``,
`This process typically takes under 30 minutes. We will send you another email once your server is ready.`,
``,
`Questions? Contact us at hello@letsbe.cloud`,
].join('\n'),
})
}
/** /**
* Clear cached transporter (useful after config changes) * Clear cached transporter (useful after config changes)
*/ */

View File

@ -1,4 +1,5 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { emailService } from './email-service'
import type { DetectedError, ErrorDetectionRule, ContainerEvent, NotificationSetting } from '@prisma/client' import type { DetectedError, ErrorDetectionRule, ContainerEvent, NotificationSetting } from '@prisma/client'
// ============================================================================ // ============================================================================
@ -326,76 +327,36 @@ LetsBe Cloud Platform
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
/** /**
* Send email to all recipients * Send email to all recipients via the centralized email service
*/ */
private async sendToRecipients( private async sendToRecipients(
recipients: string[], recipients: string[],
subject: string, subject: string,
body: string body: string
): Promise<void> { ): Promise<void> {
const resendApiKey = process.env.RESEND_API_KEY const isConfigured = await emailService.isConfigured()
if (resendApiKey) { if (!isConfigured) {
await this.sendViaResend(recipients, subject, body, resendApiKey) console.log('[Notification] Email not configured, logging notification:')
} else { console.log(`To: ${recipients.join(', ')} | Subject: ${subject}`)
// Development mode - just log console.log(body)
console.log('No email service configured, logging notification:') return
this.logEmail(recipients, subject, body)
} }
}
/** const result = await emailService.sendEmail({
* Send email via Resend API to: recipients,
*/ subject,
private async sendViaResend( html: `<pre style="font-family: monospace; white-space: pre-wrap;">${body.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`,
to: string[], text: body,
subject: string, })
text: string,
apiKey: string
): Promise<void> {
const fromEmail = process.env.RESEND_FROM_EMAIL || 'alerts@letsbe.cloud'
const fromName = process.env.RESEND_FROM_NAME || 'LetsBe Alerts'
try { if (result.success) {
const response = await fetch('https://api.resend.com/emails', { console.log(`[Notification] Email sent to ${recipients.length} recipient(s)`)
method: 'POST', } else {
headers: { console.error('[Notification] Failed to send email:', result.error)
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: `${fromName} <${fromEmail}>`,
to,
subject,
text,
}),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Resend API error: ${error}`)
}
console.log(`[Notification] Email sent to ${to.length} recipient(s)`)
} catch (error) {
console.error('[Notification] Failed to send email:', error)
throw new Error('Failed to send notification email') throw new Error('Failed to send notification email')
} }
} }
/**
* Log email for development
*/
private logEmail(to: string[], subject: string, body: string): void {
console.log('='.repeat(60))
console.log('EMAIL NOTIFICATION')
console.log('='.repeat(60))
console.log(`To: ${to.join(', ')}`)
console.log(`Subject: ${subject}`)
console.log('-'.repeat(60))
console.log(body)
console.log('='.repeat(60))
}
} }
export const notificationService = NotificationService.getInstance() export const notificationService = NotificationService.getInstance()

View File

@ -1,4 +1,5 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { emailService } from './email-service'
import crypto from 'crypto' import crypto from 'crypto'
// ============================================================================ // ============================================================================
@ -26,7 +27,8 @@ export interface VerifyCodeResult {
class SecurityVerificationService { class SecurityVerificationService {
private static instance: SecurityVerificationService private static instance: SecurityVerificationService
private readonly CODE_EXPIRY_MINUTES = 15 private readonly CODE_EXPIRY_MINUTES = 15
private readonly CODE_LENGTH = 6 private readonly CODE_LENGTH = 8
private readonly MAX_ATTEMPTS = 5
static getInstance(): SecurityVerificationService { static getInstance(): SecurityVerificationService {
if (!SecurityVerificationService.instance) { if (!SecurityVerificationService.instance) {
@ -36,12 +38,12 @@ class SecurityVerificationService {
} }
/** /**
* Generate a 6-digit verification code * Generate an 8-digit verification code
*/ */
private generateCode(): string { private generateCode(): string {
// Generate a cryptographically secure 6-digit code // Generate a cryptographically secure 8-digit code
const buffer = crypto.randomBytes(4) const buffer = crypto.randomBytes(4)
const number = buffer.readUInt32BE(0) % 1000000 const number = buffer.readUInt32BE(0) % 100000000
return number.toString().padStart(this.CODE_LENGTH, '0') return number.toString().padStart(this.CODE_LENGTH, '0')
} }
@ -119,19 +121,21 @@ class SecurityVerificationService {
} }
/** /**
* Verify a code and return the associated action/server if valid * Verify a code and return the associated action/server if valid.
* Enforces attempt limits to prevent brute-force attacks.
*/ */
async verifyCode( async verifyCode(
clientId: string, clientId: string,
code: string code: string
): Promise<VerifyCodeResult> { ): Promise<VerifyCodeResult> {
// Find the most recent active code for this client
const verificationCode = await prisma.securityVerificationCode.findFirst({ const verificationCode = await prisma.securityVerificationCode.findFirst({
where: { where: {
clientId, clientId,
code,
usedAt: null, usedAt: null,
expiresAt: { gt: new Date() } expiresAt: { gt: new Date() }
} },
orderBy: { createdAt: 'desc' }
}) })
if (!verificationCode) { if (!verificationCode) {
@ -141,6 +145,35 @@ class SecurityVerificationService {
} }
} }
// Check if max attempts exceeded
if (verificationCode.attempts >= this.MAX_ATTEMPTS) {
// Invalidate the code
await prisma.securityVerificationCode.update({
where: { id: verificationCode.id },
data: { usedAt: new Date() }
})
return {
valid: false,
errorMessage: 'Too many failed attempts. Please request a new code.'
}
}
// Check if code matches
if (verificationCode.code !== code) {
// Increment attempt counter
await prisma.securityVerificationCode.update({
where: { id: verificationCode.id },
data: { attempts: { increment: 1 } }
})
const remaining = this.MAX_ATTEMPTS - verificationCode.attempts - 1
return {
valid: false,
errorMessage: remaining > 0
? `Invalid verification code. ${remaining} attempt(s) remaining.`
: 'Too many failed attempts. Please request a new code.'
}
}
// Mark code as used // Mark code as used
await prisma.securityVerificationCode.update({ await prisma.securityVerificationCode.update({
where: { id: verificationCode.id }, where: { id: verificationCode.id },
@ -155,8 +188,7 @@ class SecurityVerificationService {
} }
/** /**
* Send verification email * Send verification email via the centralized email service
* Currently logs to console; can be replaced with Resend/SMTP
*/ */
private async sendVerificationEmail( private async sendVerificationEmail(
email: string, email: string,
@ -168,7 +200,7 @@ class SecurityVerificationService {
const actionText = action === 'WIPE' ? 'wipe' : 'reinstall' const actionText = action === 'WIPE' ? 'wipe' : 'reinstall'
const subject = `[LetsBe] Verification Code for Server ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}` const subject = `[LetsBe] Verification Code for Server ${actionText.charAt(0).toUpperCase() + actionText.slice(1)}`
const body = ` const textBody = `
Hello, Hello,
A request has been made to ${actionText} the server "${serverName}" for ${clientName}. A request has been made to ${actionText} the server "${serverName}" for ${clientName}.
@ -185,75 +217,30 @@ If you did not request this action, please ignore this email and contact support
LetsBe Cloud Platform LetsBe Cloud Platform
`.trim() `.trim()
// Check if we have email configuration const isConfigured = await emailService.isConfigured()
const resendApiKey = process.env.RESEND_API_KEY
const smtpHost = process.env.SMTP_HOST
if (resendApiKey) { if (!isConfigured) {
await this.sendViaResend(email, subject, body, resendApiKey) console.log('Email not configured, logging verification code:')
} else if (smtpHost) { console.log(`To: ${email} | Subject: ${subject}`)
// SMTP implementation would go here console.log(textBody)
console.log('SMTP email sending not yet implemented') return
this.logEmail(email, subject, body)
} else {
// Development mode - just log
console.log('No email service configured, logging email:')
this.logEmail(email, subject, body)
} }
}
/** const result = await emailService.sendEmail({
* Send email via Resend API to: email,
*/ subject,
private async sendViaResend( html: `<pre style="font-family: monospace; white-space: pre-wrap;">${textBody.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`,
to: string, text: textBody,
subject: string, })
text: string,
apiKey: string
): Promise<void> {
const fromEmail = process.env.RESEND_FROM_EMAIL || 'noreply@letsbe.cloud'
try { if (result.success) {
const response = await fetch('https://api.resend.com/emails', { console.log(`Verification email sent to ${this.maskEmail(email)}`)
method: 'POST', } else {
headers: { console.error('Failed to send verification email:', result.error)
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: fromEmail,
to: [to],
subject,
text
})
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Resend API error: ${error}`)
}
console.log(`Verification email sent to ${this.maskEmail(to)}`)
} catch (error) {
console.error('Failed to send verification email:', error)
throw new Error('Failed to send verification email') throw new Error('Failed to send verification email')
} }
} }
/**
* Log email for development
*/
private logEmail(to: string, subject: string, body: string): void {
console.log('='.repeat(60))
console.log('EMAIL NOTIFICATION')
console.log('='.repeat(60))
console.log(`To: ${to}`)
console.log(`Subject: ${subject}`)
console.log('-'.repeat(60))
console.log(body)
console.log('='.repeat(60))
}
/** /**
* Mask email for display (e.g., j***@example.com) * Mask email for display (e.g., j***@example.com)
*/ */

View File

@ -171,6 +171,15 @@ export class StorageService {
contentType: string contentType: string
): Promise<{ success: boolean; key: string; url: string | null; error?: string }> { ): Promise<{ success: boolean; key: string; url: string | null; error?: string }> {
try { try {
if (!(await this.isConfigured())) {
return {
success: false,
key,
url: null,
error: 'Storage not configured. Please configure S3/MinIO settings in Admin Settings.',
}
}
const client = await this.getClient() const client = await this.getClient()
const config = await settingsService.getStorageConfig() const config = await settingsService.getStorageConfig()
@ -206,6 +215,10 @@ export class StorageService {
*/ */
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> { async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
try { try {
if (!(await this.isConfigured())) {
return { success: false, error: 'Storage not configured' }
}
const client = await this.getClient() const client = await this.getClient()
const config = await settingsService.getStorageConfig() const config = await settingsService.getStorageConfig()
@ -231,6 +244,10 @@ export class StorageService {
*/ */
async getFile(key: string): Promise<Buffer | null> { async getFile(key: string): Promise<Buffer | null> {
try { try {
if (!(await this.isConfigured())) {
return null
}
const client = await this.getClient() const client = await this.getClient()
const config = await settingsService.getStorageConfig() const config = await settingsService.getStorageConfig()

View File

@ -0,0 +1,161 @@
import Stripe from 'stripe'
import { SubscriptionPlan, SubscriptionTier } from '@prisma/client'
if (!process.env.STRIPE_SECRET_KEY) {
console.warn('STRIPE_SECRET_KEY not configured - Stripe integration disabled')
}
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null
export interface PlanMapping {
plan: SubscriptionPlan
tier: SubscriptionTier
tokenLimit: number
tools: string[]
}
const STARTER_TOOLS = [
'nextcloud', 'keycloak', 'chatwoot', 'poste', 'n8n',
'calcom', 'umami', 'uptime-kuma', 'vaultwarden', 'portainer',
]
const PRO_TOOLS = [
...STARTER_TOOLS,
'ghost', 'nocodb', 'listmonk', 'gitea', 'minio',
'penpot', 'typebot', 'windmill', 'redash', 'glitchtip',
]
/**
* Map a Stripe Price ID to a LetsBe plan
*/
function mapPriceToplan(priceId: string): PlanMapping | null {
const starterPriceId = process.env.STRIPE_STARTER_PRICE_ID
const proPriceId = process.env.STRIPE_PRO_PRICE_ID
if (priceId === starterPriceId) {
return {
plan: SubscriptionPlan.STARTER,
tier: SubscriptionTier.ADVANCED,
tokenLimit: 10000,
tools: STARTER_TOOLS,
}
}
if (priceId === proPriceId) {
return {
plan: SubscriptionPlan.PRO,
tier: SubscriptionTier.HUB_DASHBOARD,
tokenLimit: 50000,
tools: PRO_TOOLS,
}
}
return null
}
class StripeService {
private static instance: StripeService
static getInstance(): StripeService {
if (!StripeService.instance) {
StripeService.instance = new StripeService()
}
return StripeService.instance
}
/**
* Check if Stripe is configured and available
*/
isConfigured(): boolean {
return stripe !== null
}
/**
* Verify a webhook signature and construct the event
*/
constructWebhookEvent(rawBody: string | Buffer, signature: string): Stripe.Event {
if (!stripe) {
throw new Error('Stripe is not configured')
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET is not configured')
}
return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)
}
/**
* Create a Stripe Checkout Session for a given price
*/
async createCheckoutSession(params: {
priceId: string
customerEmail?: string
successUrl: string
cancelUrl: string
metadata?: Record<string, string>
}): Promise<Stripe.Checkout.Session> {
if (!stripe) {
throw new Error('Stripe is not configured')
}
return stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: params.priceId,
quantity: 1,
},
],
customer_email: params.customerEmail,
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
})
}
/**
* Map a Stripe price ID to a LetsBe plan configuration
*/
mapPriceToPlan(priceId: string): PlanMapping | null {
return mapPriceToplan(priceId)
}
/**
* Extract plan info from a checkout session
*/
extractPlanFromSession(session: Stripe.Checkout.Session): PlanMapping | null {
// Get the price ID from the line items (available via expand or metadata)
const priceId = session.metadata?.price_id
if (priceId) {
return this.mapPriceToPlan(priceId)
}
// Fallback: check metadata for plan name
const planName = session.metadata?.plan
if (planName === 'starter') {
return {
plan: SubscriptionPlan.STARTER,
tier: SubscriptionTier.ADVANCED,
tokenLimit: 10000,
tools: STARTER_TOOLS,
}
}
if (planName === 'professional' || planName === 'pro') {
return {
plan: SubscriptionPlan.PRO,
tier: SubscriptionTier.HUB_DASHBOARD,
tokenLimit: 50000,
tools: PRO_TOOLS,
}
}
return null
}
}
export const stripeService = StripeService.getInstance()

10
startup.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/sh
set -e
echo "Running database migrations..."
prisma migrate deploy --schema=./prisma/schema.prisma 2>&1 || {
echo "WARNING: Migration failed. Starting app anyway (migrations may already be applied)."
}
echo "Starting application..."
exec node server.js

View File

@ -4,6 +4,12 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
// Mock @prisma/adapter-pg (Prisma 7 adapter not available locally on Node 23)
'@prisma/adapter-pg': path.resolve(__dirname, './src/__tests__/mocks/prisma-adapter-pg.ts'),
},
},
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,