Section 01
Network Topology
# DNS: brandlift.ai → 84.x.x.x (Brain VPS)
# DNS: *.brandlift.ai → same IP (wildcard A record)
Internet
└─ Nginx (port 443 + 80 → 443 redirect)
├─ Wildcard TLS cert (Let's Encrypt, auto-renew certbot)
├─ /public/* served from /var/www/BrandLift/public/ (bypasses Node)
└─ All other traffic → proxy_pass http://localhost:3001
localhost:3001
└─ Next.js 14 (PM2, single fork, process name: brandlift-brain)
├─ next/server middleware (runs on every request before routing)
├─ /app/api/** — all server API routes
├─ /app/admin/** — admin pages (server components, require bl_admin cookie)
├─ /app/portal/** — client portal (layout checks JWT)
└─ /app/domain-checker — public page
localhost:3306
└─ MySQL 8.0 (user: brandlift, db: brandlift)
Remote: 187.124.243.5 (Retina)
├─ Template vault (rsync + direct HTTP fetch)
├─ Ghost CMS (blog layer, unused in current flow)
└─ Playwright scraper relay scripts
Remote: 187.124.95.147 (Muscle)
├─ WordPress showroom instances
└─ Golden master deployments for client sites
Subdomain Routing
Middleware intercepts requests where hostname !== brandlift.ai and rewrites the path to /portal/proposal/{subdomain}. The x-pathname header is set on all responses so server-side layouts can distinguish proposal pages from portal pages without accessing cookies.
ℹ️
The wildcard TLS cert covers *.brandlift.ai. New subdomains are live instantly — Nginx routes them all to the same Next.js app. The proposal page queries the DB on first render: SELECT * FROM Proposal WHERE subdomain = '{sub}'
Section 02
Repository Structure
/var/www/BrandLift/
├── app/ # Next.js App Router root
│ ├── admin/ # 20 admin pages (server components)
│ ├── api/ # 120+ API route handlers
│ ├── portal/ # Client portal pages
│ │ ├── layout.tsx # Portal layout — checks auth, strips chrome on proposals
│ │ ├── PortalShell.tsx # Client component — sidebar/header (skipped for proposals)
│ │ ├── dashboard/ # /portal/dashboard
│ │ ├── proposal/[subdomain]/ # /portal/proposal/[sub] — public proposal page
│ │ └── billing/ # /portal/billing
│ ├── domain-checker/ # Public domain search page
│ ├── editor/[siteId]/ # GrapesJS site editor
│ ├── layout.tsx # Root layout
│ └── globals.css
├── components/ # Shared React components
│ ├── proposal/
│ │ ├── ForensicProposalClient.tsx # Main proposal page (~1600 lines)
│ │ └── ...
│ └── ui/
├── lib/ # Server-side utilities (never imported by client components)
│ ├── ai-foundry.ts # Gemini + Imagen wrapper
│ ├── apollo-engine.ts # Apollo.io API client
│ ├── brand-kit/ # Brand PDF, favicon, metadata generators
│ ├── enrichment/dom-crawler.ts # HTML→structured data extractor
│ ├── garbage-collector.ts # Cleanup utility for temp assets
│ ├── hunter-client.ts # Hunter.io email pattern API
│ ├── instantly-engine.ts # Instantly.ai campaign engine
│ ├── notifications/ # Slack + internal notify
│ ├── oxylabs-client.ts # Residential proxy scraper
│ ├── portal/auth.ts # JWT sign/verify, session helpers
│ ├── portal/require-auth.ts # Auth guard for portal server components
│ ├── prisma.ts # Singleton PrismaClient
│ ├── services/provision.ts # Client account + hosting provisioning
│ ├── site-auditor.ts # 20-signal SEO audit runner
│ ├── storage.ts # Local file storage helpers
│ ├── teardown-lead.ts # Lead cleanup on drop/cancel
│ └── twilio-sms.ts # Twilio SMS client
├── prisma/
│ ├── schema.prisma # Authoritative DB schema
│ └── migrations/ # Prisma migration history
├── public/ # Static assets (Nginx-served)
│ ├── generated-logos/ # Imagen 3 outputs
│ ├── template-previews/ # Industry template screenshots
│ ├── videos/ # Proposal cinematic videos
│ └── *.html # Internal docs (tech-overview, pitch, etc.)
├── middleware.ts # Next.js edge middleware
├── ecosystem.config.js # PM2 config
├── next.config.mjs # Next.js config (image domains, etc.)
├── .env # 50+ environment variables (NOT committed)
└── package.json
Section 03
Middleware Layer
File: middleware.ts. Runs on the Next.js Edge Runtime before every request. Two responsibilities:
| Condition | Action | Notes |
| hostname is a vanity subdomain (not brandlift.ai, not www) | NextResponse.rewrite(url) where url.pathname = /portal/proposal/{sub} | Sets x-pathname header on the rewrite response. Subdomain extracted from request.headers.get('host'). |
| All other requests | NextResponse.next() | Sets x-pathname header = request.nextUrl.pathname. Used by app/portal/layout.tsx to detect proposal pages. |
⚠️
Critical pattern: x-pathname header is the only reliable way for server components to know the "real" path. usePathname() returns the browser URL (e.g., "/") on subdomain requests, not the rewritten path. Never use usePathname() in server components to detect proposals.
Section 04
Authentication Model
| Surface | Mechanism | Cookie | TTL | Guard |
| Admin | Manual cookie set via /api/admin/set-admin-cookie. Cookie value = env ADMIN_SECRET. No password UI. | bl_admin | Session | Checked in each admin route: cookies().get('bl_admin')?.value === process.env.ADMIN_SECRET |
| Client Portal | Email + PBKDF2 password hash. JWT signed with PORTAL_JWT_SECRET (HS256). Stored as http-only cookie. | bl_portal_session | 7 days | lib/portal/require-auth.ts — redirects to /portal/login if invalid |
| Admin Emulation | Admin visits /api/admin/emulate-client?leadId={id}. Server mints a 2-hour JWT with emulated:true. If no ClientAccount, uses preview payload (preview:true). | bl_portal_session | 2 hours | Same portal guard. Portal UI shows "⚡ Admin View" banner when emulated=true. |
| SSO Link | /api/sso accepts signed token in URL, sets portal session cookie, redirects to portal dashboard. | bl_portal_session | 7 days | Token verified against PORTAL_JWT_SECRET |
JWT Payload Shape
{
email: string, // client email or "preview-{leadId}@brandlift.ai"
company_name: string, // from ClientAccount or lead data
active_plan: string, // "Launch" | "Growth" | "Authority"
emulated?: boolean, // present on admin emulation sessions
admin_origin?:boolean, // came from /api/admin/emulate-client
preview?: boolean, // no real ClientAccount exists
iat: number, // issued at (unix)
exp: number // expiry (unix)
}
ℹ️
Admin auth has no rate limiting or brute-force protection. The admin cookie is a simple shared secret. This is acceptable for a private VPS with no public login form, but should be hardened before any team expansion (add IP allowlist or TOTP).
Section 05
Database Schema
32 Prisma models. MySQL 8. Key relationships below.
-- Primary lead-to-client chain
LeadReservoir (1) ──── (many) Proposal
Proposal (many) ──── (1) ClientAccount [optional — null until payment]
ClientAccount (1) ──── (many) ClientSite
ClientSite (many) ──── (1) SiteTemplate
-- Email tracking
LeadReservoir (1) ──── (many) EmailEvent [ON DELETE CASCADE]
-- Hosting infrastructure
HostingPlan (1) ──── (many) ServerSlot [status: AVAILABLE | ACTIVE | ERROR]
-- Brand assets
Proposal (1) ──── (many) GeneratedLogo [Imagen 3 outputs for a lead]
-- Support system
SupportTicket (1) ──── (many) TicketComment
SupportTicket (many) ──── (1) HostingerEscalation [optional]
-- AI knowledge base
AdamKnowledge (standalone) — FAQ articles for ADAM AI
AdamQA (standalone) — Q&A pairs for ADAM training
-- Web crawling
WebCrawlerJob (standalone) — OpenClaw crawl tasks + results JSON
| Model | Key Fields | Notes |
LeadReservoir | domain_key (unique), company_name, contact_email, enrichment_status, target_score (0–100), site_audit_json (JSON blob), pathway (migrate|rebrand) | Core lead table. Unique index on domain_key prevents duplicates across scrape runs. |
Proposal | subdomain (unique), lead_id (FK→LeadReservoir), client_id (FK→ClientAccount, nullable), custom_data (JSON), status | custom_data stores brand kit selections, pathway flag, template choice. |
ClientAccount | email (unique), password_hash, active_plan, stripe_customer_id, stripe_subscription_id, portal_access (bool) | Created by Stripe webhook on checkout.session.completed. |
EmailEvent | lead_id (FK→LeadReservoir, CASCADE), event_type (SENT|OPENED|CLICKED|REPLIED|BOUNCED|UNSUBSCRIBED), apollo_message_id, instantly_campaign_id | Fed by Instantly webhook at /api/webhooks/instantly. |
GeneratedLogo | lead_id (FK), image_url, prompt_used, is_selected (bool), style_key | Imagen 3 outputs. Up to 4 per request. Stored in /public/generated-logos/{leadId}/. |
ServerSlot | slot_name, status (AVAILABLE|ACTIVE|ERROR), client_id (nullable FK), hostinger_vm_id | One row per Hostinger hosting account slot. |
SiteTemplate | name, industry, preview_url, html_content (TEXT), is_active, is_hero | Template HTML and metadata for the selector. |
ClientSite | client_id (FK), template_id (FK), site_data (JSON — GrapesJS pages/styles), published (bool) | Live site data. Edited by client in GrapesJS editor. |
GlobalSettings | key (unique), value (TEXT) | KV store for admin-configurable settings: pricing tiers, default email templates. |
SupportTicket | subject, status (OPEN|IN_PROGRESS|RESOLVED), priority, client_id (nullable) | Internal support console. ADAM AI handles first contact, escalates to human. |
SuppressionList | email (unique), reason (UNSUBSCRIBED|BOUNCED|MANUAL) | Checked before every email send. /api/unsubscribe writes here. |
SystemEvent | type, payload (JSON), created_at | Audit log. Written on lead release, proposal creation, payment events. |
WebCrawlerJob | url, goal (natural language), status, result_json | OpenClaw jobs. Admin creates, server processes with Playwright + Gemini. |
Section 06
Prisma Configuration
# prisma/schema.prisma
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
DATABASE_URL format: mysql://brandlift:password@localhost:3306/brandlift?connection_limit=10
Singleton client in lib/prisma.ts prevents connection pool exhaustion in development hot-reload cycles:
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
⚠️
Migrations: Always run npx prisma migrate deploy (not dev) in production after schema changes. migrate dev can auto-generate migrations from drift — unsafe on live data.
ℹ️
Raw SQL: Some admin operations (data wipes, bulk status updates) use prisma.$executeRawUnsafe() with SET FOREIGN_KEY_CHECKS=0 patterns. These are dangerous — always wrap in a transaction and test on a backup first.
Section 07
Admin API Routes
All admin routes check for bl_admin cookie. Return 401 if missing/invalid.
| Route | Methods | Purpose |
/api/admin/leads | GET | Paginated lead reservoir. Supports filter by status, enrichment_status, search. Includes nested proposals and email events. |
/api/admin/leads/[id] | PUT DEL | Update lead fields (status, notes, pathway). DELETE triggers teardown-lead.ts which cascades EmailEvents. |
/api/admin/proposals | GET POST | GET returns all proposals with lead + client joins. POST creates a new proposal (release action) and triggers Instantly email. |
/api/admin/emulate-client | GET | Mints a portal JWT for a lead's client (or preview JWT if no client). Sets bl_portal_session cookie. Redirects to /portal/dashboard. |
/api/admin/set-admin-cookie | POST | Sets bl_admin cookie. Accepts secret in body. Should be called from a trusted device only. |
/api/admin/clients | GET | Returns all ClientAccount rows with associated proposals. |
/api/admin/hosting-slots | GET | Returns all ServerSlot rows. Grouped by HostingPlan. |
/api/admin/hosting-plans/sync | POST | Syncs slot inventory from Hostinger API. Creates ServerSlot rows for new slots. |
/api/admin/templates | GET POST | CRUD for SiteTemplate records. POST accepts template HTML + metadata. |
/api/admin/templates/recapture-hero | POST | Runs Playwright screenshot capture on a template URL. Saves to /public/template-previews/. |
/api/admin/vault-cleanup | POST | Removes orphaned files from /public/generated-logos/ that have no matching DB record. |
/api/admin/fleet | GET | Health check for Brain and Retina nodes. Returns CPU, memory, uptime. |
/api/admin/settings | GET PUT | GlobalSettings KV CRUD. Admin settings page writes here. |
/api/admin/email-templates | GET POST | EmailTemplate CRUD. Templates used by Instantly engine for proposal outreach. |
/api/admin/stats/email | GET | Aggregate email event stats: SENT/OPENED/CLICKED/REPLIED counts by time period. |
/api/admin/deploy-golden-master | POST | Triggers golden master WordPress deployment on Muscle node via SSH + rsync. |
/api/admin/cms-white-label | GET POST | Read/write GlobalWpWhiteLabelConfig. Controls WordPress admin branding. |
/api/admin/crawl | POST | Triggers Oxylabs scrape for a target URL. Returns raw HTML. Used by OpenClaw page. |
/api/admin/media-library | GET POST | Browse and upload images to /public/media/{category}/. |
/api/admin/pre-eval/[client_id] | GET | Pre-publication checklist for a client site: domain DNS status, template selected, brand data complete. |
Section 08
Brand & AI API Routes
| Route | Method | Auth | Purpose |
/api/brand/generate-logo | POST | public | Body: {company_name, industry, colors, style}. Calls Imagen 3 API. Returns 4 logo image URLs. Saves to /public/generated-logos/{leadId}/. Creates GeneratedLogo records. |
/api/brand/taglines | POST | public | Body: {company_name, industry, tone}. Gemini Flash → returns 4 tagline strings. |
/api/brand/site-audit | POST | public | Body: {url}. Runs site-auditor.ts. Returns full 20-signal audit JSON with composite score. |
/api/brand/complete-kit | POST | public | Body: brand kit selections. Generates PDF brand kit via brand-kit/pdf-document.ts + favicons. Returns download URLs. |
/api/brand/kit/[...slug] | GET | public | Serves a previously generated brand kit asset by path segments. |
/api/brand/logos | GET | public | Query: {leadId}. Returns all GeneratedLogo records for a lead. |
/api/generate-seo | POST | public | Gemini-powered SEO copy generation: meta titles, descriptions, headings for a business. |
/api/generate-hero | POST | public | Imagen 3 hero image generation for a site's above-the-fold section. |
/api/domain-check | GET | public | Query: {domain}. Checks WHOIS availability. Returns {status: available|taken|error}. Used by domain-checker page. |
/api/domain-curate | POST | admin | Gemini-powered domain name suggestions for a business + available WHOIS check loop. |
/api/webcrawler/execute | POST | admin | Run an OpenClaw job: Playwright fetches URL, Gemini extracts structured data per goal. |
/api/webcrawler/generate-prompt | POST | admin | Gemini generates an optimized Playwright+AI extraction prompt from a natural language goal. |
/api/xray/template-preview/[id] | GET | admin | Returns X-Ray annotated screenshot for a template (CSS injection map overlay). |
/api/xray/calibrate | POST | admin | Runs Playwright on a template, captures screenshot + computed CSS map for brand injection calibration. |
Section 09
Portal API Routes
| Route | Method | Auth | Purpose |
/api/stripe/checkout | POST | public | Body: {plan, proposalId}. Creates Stripe Checkout Session. Returns {url} for redirect. |
/api/stripe/portal | POST | portal | Creates Stripe Billing Portal session for the authenticated client. Returns {url}. |
/api/stripe/webhook | POST | Stripe sig | Handles: checkout.session.completed (create ClientAccount), invoice.paid, customer.subscription.deleted. Verifies Stripe-Signature header. |
/api/sso | GET | JWT in query | SSO link handler. Validates token, sets bl_portal_session cookie, redirects to /portal/dashboard. |
/api/dashboard/stats | GET | portal | Returns client's site metrics, billing summary, recent events. |
/api/editor/site | GET | portal | Returns GrapesJS pages/styles JSON for client's ClientSite. |
/api/editor/save | POST | portal | Saves GrapesJS pages/styles JSON to ClientSite.site_data. Marks as draft. |
/api/editor/upload-image | POST | portal | Multipart upload. Sharp resizes, saves to /public/client-assets/{clientId}/. Returns URL. |
/api/editor/vault-images | GET | portal | Returns vault image URLs filtered by client's industry category. |
/api/support/tickets | GET POST | portal | GET: client's ticket history. POST: create new ticket (body: subject, description, priority). |
/api/support/adam-chat | POST | portal | Body: {message}. ADAM AI responds using AdamKnowledge + AdamQA context. Gemini Flash with system prompt. |
/api/track/proposal-view | POST | public | Body: {subdomain}. Creates SystemEvent + updates Proposal.last_viewed_at. Called on proposal page mount. |
/api/unsubscribe | GET | public | Query: {email, token}. Verifies token, inserts into SuppressionList, returns unsubscribe confirmation page. |
Section 10
Inbound Webhook Routes
| Route | Source | Events Handled |
/api/webhooks/instantly | Instantly.ai | email_sent, email_opened, email_clicked, email_replied, email_bounced, email_unsubscribed → Upsert EmailEvent. Update LeadReservoir.last_email_event. Fire SystemEvent. |
/api/webhooks/apollo | Apollo.io | contact_enriched → Update LeadReservoir contact fields. Trigger audit if not yet run. |
/api/webhooks/client-events | Client portal JS | Proposal interaction events: brand_kit_started, template_selected, plan_viewed. Creates ClientEvent records. |
/api/webhooks/system-events | Internal nodes | Retina/Muscle node health reports. Creates SystemEvent records. Triggers Fleet Window alerts. |
/api/stripe/webhook | Stripe | checkout.session.completed, invoice.paid, customer.subscription.deleted, customer.subscription.updated |
⚠️
Stripe webhook secret: The STRIPE_WEBHOOK_SECRET env var must match the webhook signing secret in the Stripe dashboard. After any Stripe dashboard change, update the env var and restart PM2. Without this, all Stripe events will be rejected as invalid.
Section 11
Public / Lead Pipeline Routes
| Route | Purpose |
/api/generate-lead | Core pipeline entry: accepts target {industry, city}, runs Oxylabs scrape, enrichment, SEO audit, stores LeadReservoir rows. Long-running — returns job_id, status polled via /api/pipeline/status. |
/api/apollo/enrich | Standalone Apollo enrichment for a specific domain. Called from the reservoir "Enrich" button. |
/api/inbound-lead | Public landing page form handler. Creates LeadReservoir row with source=inbound. Sends internal notification. |
/api/industries | Returns all IndustryCategory rows. Used by Lead Engine target selector and template gallery filters. |
/api/site-templates/[id] | Returns SiteTemplate HTML + metadata. Served to proposal page template selector and admin template gallery. |
/api/vault-assets | Returns VaultCategory assets filtered by category and type. Served to GrapesJS editor image picker. |
/api/template-previews | Returns SiteTemplate rows with preview_url. Filtered by industry. Used by proposal template carousel. |
/api/cron/garbage-collect | Called by cron job (0 4 * * *). Cleans orphaned files via garbage-collector.ts. |
/api/cron/webcrawler | Called by cron job. Processes pending WebCrawlerJob rows. |
Section 12
Core Library Files
| File | Exports | Notes |
lib/ai-foundry.ts | generateTaglines(), generateEmailCopy(), generateLogo(), extractStructured() | Central wrapper for all Google AI calls. Uses @google/generative-ai SDK. Handles retries and structured output parsing. |
lib/oxylabs-client.ts | fetchSERP(), fetchPage() | Residential proxy requests. Max 5 concurrent via p-limit. 3 retries with exponential backoff. Credentials from OXYLABS_USERNAME + OXYLABS_PASSWORD. |
lib/site-auditor.ts | auditSite(url) | Returns {scores, signals, composite_score}. Calls Google PageSpeed API for performance scores. Fetches and parses HTML for the remaining 16 signals. |
lib/apollo-engine.ts | enrichDomain(), searchPeople() | Apollo.io REST API v1. Returns contacts with title filter for decision-makers. Rate limit: 50 req/min. |
lib/hunter-client.ts | findEmailPattern(), verifyEmail() | Hunter.io domain search + email verify endpoints. |
lib/instantly-engine.ts | sendProposalEmail(), addToCampaign() | Instantly.ai v2 API. Adds lead to campaign, creates email with personalization variables. Campaign ID from INSTANTLY_CAMPAIGN_ID env var. |
lib/portal/auth.ts | signJWT(), verifyJWT(), getPortalSession() | JOSE library. HS256 signing with PORTAL_JWT_SECRET. getPortalSession() reads bl_portal_session cookie from Next.js cookies() — server-side only. |
lib/portal/require-auth.ts | requireAuth() | Async server component guard. Calls getPortalSession(), redirects to /portal/login on failure. |
lib/services/provision.ts | provisionClient() | Called by Stripe webhook on checkout.completed. Creates ClientAccount, links Proposal, assigns ServerSlot, sends welcome email. |
lib/enrichment/dom-crawler.ts | crawlPage() | Fetches and parses homepage HTML. Returns {phone, email, address, staff_names, meta_desc, social_links}. |
lib/twilio-sms.ts | sendSMS() | Twilio REST client. From number from TWILIO_PHONE_NUMBER env var. |
lib/teardown-lead.ts | teardownLead() | Cascading delete for a lead: EmailEvents, GeneratedLogos, Proposals (if not client-linked). Does NOT delete active ClientAccount. |
lib/notifications/notify.ts | notify() | Dispatches internal notifications. Routes to Slack (SLACK_WEBHOOK_URL) and/or creates SystemEvent record based on NotificationConfig. |
lib/prisma.ts | prisma | Singleton PrismaClient. Prevents connection pool exhaustion in dev. |
lib/garbage-collector.ts | garbageCollect() | Walks /public/generated-logos/ and /public/template-previews/, removes files with no corresponding DB record. |
Section 13
External Dependencies & Environment Variables
| Service | Env Var(s) | Failure Mode | Circuit Breaker? |
| Google Gemini | GEMINI_API_KEY | AI copy/taglines fail. Proposals render with placeholder text. | try/catch, returns fallback strings |
| Google Imagen 3 | GOOGLE_API_KEY | Logo gen fails. User sees error state in Brand Configurator. | try/catch, returns {error: true} |
| Google PageSpeed | PAGE_SPEED_INSIGHTS_API_KEY | Performance scores default to 0 in audit. Other signals still run. | try/catch, score = 0 |
| Oxylabs | OXYLABS_USERNAME, OXYLABS_PASSWORD | Lead discovery pipeline completely halts. | 3 retries + throw |
| Apollo.io | APOLLO_API_KEY | Contact enrichment step skipped. Lead remains unenriched. | try/catch, skip to Hunter |
| Hunter.io | HUNTER_API_KEY | Email pattern fallback fails. Lead status = no_contact. | try/catch, skip |
| Instantly.ai | INSTANTLY_API_KEY, INSTANTLY_CAMPAIGN_ID | Proposal email not sent. Lead released but email_status = failed. | try/catch, writes error to SystemEvent |
| Twilio | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER | SMS outreach fails. Email path unaffected. | try/catch, skip |
| Stripe | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | No new checkouts. Webhooks fail → clients not provisioned after payment. | Stripe SDK throws, 500 returned |
| Hostinger | HOSTINGER_API_TOKEN | Slot sync fails. Existing clients unaffected. New slot assignments blocked. | try/catch, returns cached data |
| Slack | SLACK_WEBHOOK_URL | Admin notifications not delivered. Platform continues operating. | try/catch, silent fail |
| Retina node | RETINA_HOST, RETINA_API_KEY | Template fetch from vault fails. Admin template gallery shows empty. | try/catch, returns [] |
| MySQL | DATABASE_URL | Entire platform is non-functional. All pages 500. | Prisma throws, no fallback |
Section 14
Deployment Procedures
Standard Deploy
cd /var/www/BrandLift
git pull origin main
npm install # if package.json changed
npx prisma migrate deploy # if schema changed
npm run build # ~2 min
pm2 restart brandlift-brain
pm2 logs brandlift-brain --lines 50 # verify clean start
PM2 Configuration (ecosystem.config.js)
module.exports = {
apps: [{
name: 'brandlift-brain',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/BrandLift',
env: { PORT: 3001, NODE_ENV: 'production' },
max_memory_restart: '1G',
instances: 1, // single fork — not cluster
autorestart: true,
watch: false,
}]
}
Nginx Config Summary
# /etc/nginx/sites-available/brandlift-brain
server {
listen 443 ssl;
server_name brandlift.ai *.brandlift.ai;
ssl_certificate /etc/letsencrypt/live/brandlift.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/brandlift.ai/privkey.pem;
# Static assets bypass Node.js
location /generated-logos/ { alias /var/www/BrandLift/public/generated-logos/; }
location /template-previews/ { alias /var/www/BrandLift/public/template-previews/; }
location / {
proxy_pass http://localhost:3001;
proxy_set_header Host $host; # CRITICAL: preserves subdomain for middleware
proxy_set_header X-Real-IP $remote_addr;
}
}
⚠️
proxy_set_header Host $host is critical. Without it, subdomain requests lose the hostname and middleware can't detect vanity subdomains. Always verify after any Nginx config change: nginx -t && nginx -s reload
ℹ️
TLS cert renewal: Certbot cron auto-renews. Run certbot renew --dry-run to verify. The cert covers *.brandlift.ai (wildcard) which requires DNS-01 challenge — ensure Cloudflare API token is configured in certbot.
Section 15
Known Risks
HIGH
Single Point of Failure — Brain VPS
All services (Next.js, MySQL, static assets) run on one server. A hardware failure or extended outage brings down the entire platform including client portals, payments, and admin. No failover configured. Mitigation: daily MySQL backup to Retina node via cron (mysqldump | gzip → scp). Recovery time: ~4 hours from backup.
HIGH
No Admin Authentication Rate Limiting
/api/admin/set-admin-cookie accepts any POST body without rate limiting. If the admin secret leaks, full admin access is trivial. Mitigations needed: IP allowlist, TOTP, or VPN-only access for /admin/* routes.
MED
Stripe Webhook Without Idempotency Guard
checkout.session.completed can theoretically fire multiple times (Stripe retries). provisionClient() should check if ClientAccount already exists before creating. Currently relies on unique constraint on email to prevent duplicates — this throws an error rather than gracefully deduplicating.
MED
Synchronous Playwright in Request Handlers
Several API routes (xray/calibrate, webcrawler/execute, template-screenshot) launch Playwright browsers synchronously within the request/response cycle. Under load, this exhausts Node.js threads and delays all other requests. Should move to a job queue (BullMQ + Redis).
MED
No CSRF Protection on State-Mutating Routes
Admin routes rely solely on the bl_admin cookie value match. There is no CSRF token mechanism. A CSRF attack from a page loaded while the admin cookie is active could trigger admin actions. Mitigations: SameSite=Strict on admin cookie, add CSRF token header check.
LOW
Generated Logo Files Grow Unboundedly
/public/generated-logos/ accumulates Imagen 3 outputs indefinitely. Garbage collector runs at 4AM but only removes orphaned records. Active leads' logos are kept forever. At high scale (10k+ leads), disk usage becomes a concern. Add 30-day retention policy for unselected logos.
LOW
Oxylabs Session Limits Under Batch Processing
Max 5 concurrent Oxylabs sessions (p-limit). Large batch scrapes (50+ leads) take 10–20 minutes. There's no progress reporting to the admin during this period — the Lead Engine page shows a spinner. Add Server-Sent Events or polling endpoint for batch job status.
Section 16
Technical Debt Register
| Area | Debt | Impact | Priority |
ForensicProposalClient.tsx | ~1,600 lines in a single file. Multiple embedded sub-components (MigrateShowreel, BrandConfigurator, DomainTransferZone, etc.). No separation of concerns. | High cognitive load for edits. Hard to test individual sections. | Med — refactor into /components/proposal/ sub-files |
| Admin routes auth | Cookie comparison is repeated inline in every admin API handler. No centralized middleware guard for admin routes. | Any new admin route that forgets the check is publicly accessible. | High — extract to lib/admin/require-admin.ts |
| Error handling | Most API routes return generic {error: 'something failed'} without structured error codes. Difficult to debug client-side failures. | Makes frontend error handling imprecise. | Low — add error code enum |
| Type safety on JSON blobs | site_audit_json, custom_data, result_json, site_data are all untyped JSON. Prisma treats them as Json type — no TypeScript inference on contents. | Runtime errors when schema of JSON blobs changes. | Med — add Zod schemas for each JSON blob |
| No test suite | Zero unit or integration tests. All validation is manual + Playwright smoke tests. | Regressions are caught only in production. | High — add Vitest for lib/* and Playwright for critical user flows |
| PM2 single fork | Single Node.js process handles all requests including CPU-intensive Playwright and image processing. | Slow Playwright tasks block concurrent requests. | Med — move to cluster mode or extract to worker |
| No query result caching | The Lead Reservoir page queries LeadReservoir + Proposals + EmailEvents on every page load (~50ms at current scale, degrades with growth). | Performance degrades linearly with lead count. | Low — add Redis cache with 30s TTL for reservoir list queries |
| Inline styles throughout | ForensicProposalClient.tsx and several proposal sub-components use 100% inline React style objects (~40,000 chars of inline CSS). | No theming, hard to override, terrible for style auditing. | Low — acceptable for current scale, migrate to CSS modules in next major refactor |
✅
The platform is production-stable at current scale (50–200 clients, 10k leads). The risks above are real but manageable. The highest-priority items are: centralizing admin auth, adding idempotency to the Stripe webhook, and moving Playwright to a background queue. Everything else can be addressed incrementally.