1 line
60 KiB
JSON
1 line
60 KiB
JSON
{"repo": "badlogic/sitegeist", "files": {"db.md": {"104788c68e624a9705a9ee90f1d0b0176ad28747": {"t": 1785674406.8598535, "n": 56561, "h": "17c69dc057ee46e1", "c": "# Sitegeist Database & Subscription Architecture\n\n**Date:** 2025-10-24\n**Status:** Architecture Design Phase\n\n---\n\n## Table of Contents\n1. [Requirements](#requirements)\n2. [Current Infrastructure](#current-infrastructure)\n3. [Storage Options Analysis](#storage-options-analysis)\n4. [Recommendation](#recommendation)\n5. [Database Schema](#database-schema)\n6. [API Endpoints](#api-endpoints)\n7. [Payment Flow](#payment-flow)\n8. [Implementation Plan](#implementation-plan)\n\n---\n\n## Requirements\n\n### User Flow\n1. **Installation** - User installs Sitegeist browser extension\n2. **Authentication** - If not logged in, user is prompted to log in or create account (email + password)\n3. **Email Verification** - Account creation requires one-time code via email for verification\n4. **Trial Period** - New accounts get $1.00 in token credits with Sitegeist-provided Haiku model\n - **No BYOK required during trial** - Sitegeist handles API keys and model access\n - Extension sends requests to backend, which proxies to AI provider\n - Server tracks token usage in dollars but **NOT message content** (privacy-first)\n - Usage deducted per request: prompt tokens + completion tokens = cost in credits\n - User can finish current session but cannot create new sessions when credits depleted\n5. **Paywall** - After $1.00 credits exhausted, user is presented with subscription option ($5-10/month)\n6. **Payment** - Subscription action redirects to payment provider (Stripe/LemonSqueezy/Paddle)\n7. **Status Sync** - Extension polls server for subscription status until payment completes or is cancelled\n8. **Post-Payment** - Paid users can use BYOK or continue with Sitegeist-provided models (unlimited)\n\n### Data Storage Requirements\nPer-user data on server:\n- **User Account**: email, password hash, email verification status\n- **Plan Status**: trial, paid, expired\n- **Token Credits**: Remaining balance during trial (e.g., $0.47 remaining)\n- **Token Usage Stats**: Total tokens used, total cost (for analytics, not per-message tracking)\n- **Payment Provider Data**: customer ID, subscription ID, subscription status\n\n### Non-Requirements\n- ❌ Multi-device sync (user data lives locally in extension, only auth/subscription on server)\n- ❌ Message content storage (privacy-first design)\n- ❌ Per-message history or detailed token logs (only aggregate totals)\n- ❌ Complex user profiles or settings\n\n---\n\n## Current Infrastructure\n\n### Existing Server: slayer.marioslab.io\n\n**Hardware Specs:**\n```\nCPU: 32 cores (current load: 0.10-0.17)\nRAM: 62GB total, 45GB available (14GB used)\nDisk: 1.7TB total, 1.4TB free (258GB used)\nUptime: 97+ days\nRegion: Germany (Hetzner)\n```\n\n**Current Load:**\n- Elasticsearch: ~7.5GB RAM (11.7%)\n- Node.js services: ~2-3GB RAM total\n- Docker/containerd: minimal\n- **≈99% CPU capacity unused**\n- **≈70% RAM capacity unused**\n\n**Can Easily Support:**\n- 50,000+ Sitegeist users\n- No performance impact on existing services\n- Zero additional hosting cost\n\n### Existing Site Infrastructure\n\n**Location:** `/site` directory\n\n**Deployment Stack:**\n```yaml\n# site/infra/docker-compose.yml\nservices:\n web: # Caddy (reverse proxy + static files)\n backend: # Node.js Express API server\n```\n\n**Current Storage:**\n```typescript\n// site/src/backend/storage.ts\nclass FileStore<T> {\n // Single JSON file key/value store\n // Loads entire file on init, writes on modification\n // Currently used for: signups.json, settings.json\n}\n```\n\n**Deployment Flow:**\n```bash\n# Local development\n./run.sh dev # tsx watch + vite dev server\n\n# Build for production\n./run.sh build # tsc + vite build\n\n# Deploy to slayer.marioslab.io\n./run.sh deploy # build + rsync + docker compose restart\n\n# On server\n./run.sh prod # docker compose up -d\n./run.sh logs # docker compose logs -f\n```\n\n**Server Location:** `/home/badlogic/sitegeist.ai`\n\n**Reverse Proxy:** Caddy with automatic HTTPS (sitegeist.ai)\n\n---\n\n## Storage Options Analysis\n\n### Option 1: JSON Files (FileStore)\n\n**Current Implementation:**\n```typescript\n// site/src/backend/storage.ts\nconst signupsStore = new FileStore<EmailSignup[]>('./data/signups.json');\n```\n\n**Pros:**\n- ✅ Already implemented in codebase\n- ✅ Zero setup, identical local/prod\n- ✅ No additional services needed\n- ✅ Works offline\n- ✅ Simple backups (just copy JSON files)\n- ✅ No external dependencies\n\n**Cons:**\n- ❌ **Race conditions** on concurrent writes (multiple extensions calling API simultaneously)\n- ❌ **No atomic operations** (message increment + limit check = unsafe)\n- ❌ **Critical for payments** (subscription status updates must be atomic)\n- ❌ **Performance degrades** with many users (loads entire file into memory)\n- ❌ **No transactions** (can't rollback failed operations)\n- ❌ **Risky for payment data** (corrupted file = lost subscription data)\n\n**Verdict:** ⚠️ **Only viable for <50 users or MVP validation phase**\n\n---\n\n### Option 2: Self-Hosted PostgreSQL on slayer.marioslab.io\n\n**Setup:**\n```yaml\n# Add to site/infra/docker-compose.yml\nservices:\n postgres:\n image: postgres:16\n restart: unless-stopped\n environment:\n POSTGRES_DB: sitegeist\n POSTGRES_USER: sitegeist\n POSTGRES_PASSWORD: ${DB_PASSWORD}\n volumes:\n - postgres_data:/var/lib/postgresql/data\n networks:\n - internal\n\nvolumes:\n postgres_data:\n```\n\n**Local Development:**\n```yaml\n# docker-compose.dev.yml (local)\nservices:\n postgres:\n image: postgres:16\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_DB: sitegeist_dev\n POSTGRES_PASSWORD: dev\n```\n\n**Pros:**\n- ✅ **ACID transactions** (critical for message counting & payments)\n- ✅ **Proper concurrency** control (no race conditions)\n- ✅ **Atomic operations** (increment + check in single transaction)\n- ✅ **No recurring costs** (runs on existing server)\n- ✅ **Full control** over data and backups\n- ✅ **Production-ready** for 10k+ users\n- ✅ **Same server** you already manage\n\n**Cons:**\n- ⚠️ Requires Docker Compose or PostgreSQL install for local dev\n- ⚠️ Need to manage migrations (Drizzle/Prisma helps)\n- ⚠️ Manual backups (can automate with cron)\n- ⚠️ More complex than JSON files\n\n**Resource Impact on slayer.marioslab.io:**\n```\nPostgreSQL (10k users):\n- RAM: ~500MB (0.8% of available 45GB)\n- CPU: <1% of one core\n- Disk: ~1-2GB (0.1% of available 1.4TB)\n\nTotal Impact: Negligible\n```\n\n**Verdict:** ✅ **Best option for production on existing infrastructure**\n\n---\n\n### Option 3: Cloud Managed Database (EU)\n\n#### Option 3a: Neon (Serverless PostgreSQL)\n```\nRegion: Frankfurt, Germany\nFree Tier: 0.5GB storage, 3GB compute\nPaid: $19/month when exceeded\nFeatures: Auto-scaling, branching, connection pooling\n```\n\n#### Option 3b: Railway\n```\nRegion: EU available\nStarter: $5/month + usage\nFeatures: PostgreSQL + deployment platform\n```\n\n#### Option 3c: DigitalOcean Managed Database\n```\nRegion: Frankfurt, Amsterdam\nMinimum: $15/month\nFeatures: Auto backups, point-in-time recovery\n```\n\n#### Option 3d: Render\n```\nRegion: Frankfurt\nFree Tier: 90 days only\nPaid: $7/month starter\n```\n\n**Pros:**\n- ✅ Zero database ops (backups, updates automatic)\n- ✅ EU data residency (GDPR compliant)\n- ✅ Easy dev/prod setup (different projects, same code)\n- ✅ Auto-scaling\n\n**Cons:**\n- ❌ **Recurring costs** ($5-25/month)\n- ❌ **External dependency** (can't access if service is down)\n- ❌ **Vendor lock-in**\n- ❌ **Unnecessary** (you have 99% unused server capacity)\n\n**Verdict:** ⚠️ **Not recommended** - adds cost and complexity when you have available infrastructure\n\n---\n\n## Recommendation\n\n### Phase 1: PostgreSQL on slayer.marioslab.io\n\n**Why:**\n1. ✅ **Zero additional cost** (runs on existing server with 99% unused capacity)\n2. ✅ **Production-ready** (ACID transactions for payment operations)\n3. ✅ **EU-hosted** (Germany - GDPR compliant)\n4. ✅ **Simple deployment** (add to existing docker-compose.yml)\n5. ✅ **Scales easily** (can handle 50k+ users on current hardware)\n\n**Tech Stack:**\n```\nDatabase: PostgreSQL 16 (Docker container)\nORM: Drizzle (lightweight, SQL-like syntax)\nAuth: bcrypt + JWT (simple, no external dependencies)\nPayments: Stripe (most popular) or Paddle (EU-friendly)\n```\n\n**Why Drizzle over Prisma:**\n- Lighter bundle size (~1MB vs ~30MB)\n- SQL-like syntax (easier to reason about)\n- Better for extensions (smaller bundle)\n- Type-safe with minimal overhead\n\n### Migration Path (if needed)\n\nIf you grow beyond server capacity or want managed services later:\n```bash\n# 1. Export from PostgreSQL\npg_dump sitegeist > backup.sql\n\n# 2. Import to managed service (Neon/Railway)\npsql $NEW_DATABASE_URL < backup.sql\n\n# 3. Update environment variable\nDATABASE_URL=postgresql://new-host/sitegeist\n\n# 4. Done - zero code changes\n```\n\n---\n\n## AI Proxy Architecture\n\n### Overview\n\nSince trial users don't provide their own API keys (BYOK), the backend must proxy AI requests to Anthropic on their behalf. This requires:\n\n1. **Server-side API key management** - Store Sitegeist API keys securely\n2. **Request proxying** - Forward extension requests to Anthropic API\n3. **Token usage tracking** - Calculate costs and deduct from user credits\n4. **Credit enforcement** - Prevent usage when credits exhausted\n\n### Architecture Diagram\n\n```\n┌─────────────────┐\n│ Extension │\n│ (Frontend) │\n└────────┬────────┘\n │ POST /api/ai/chat\n │ { messages: [...], model: \"haiku\" }\n │ Authorization: Bearer <jwt>\n ↓\n┌─────────────────────────────────────────────┐\n│ Backend Server (Node.js + Express) │\n├─────────────────────────────────────────────┤\n│ 1. Authenticate user (JWT) │\n│ 2. Check subscription plan & credits │\n│ 3. Forward to Anthropic API │\n│ 4. Calculate cost from usage │\n│ 5. Deduct credits (atomic transaction) │\n│ 6. Return response + updated status │\n└────────┬────────────────────────────────────┘\n │\n │ POST https://api.anthropic.com/v1/messages\n │ x-api-key: <sitegeist_api_key>\n │ { messages: [...], model: \"claude-3-haiku-20240307\" }\n ↓\n┌─────────────────┐\n│ Anthropic API │\n└─────────────────┘\n```\n\n### Token Pricing (Anthropic)\n\n```typescript\n// src/backend/ai/pricing.ts\nexport const MODEL_PRICING = {\n 'claude-3-haiku-20240307': {\n inputPer1M: 0.25, // $0.25 per 1M input tokens\n outputPer1M: 1.25, // $1.25 per 1M output tokens\n },\n 'claude-3-5-sonnet-20241022': {\n inputPer1M: 3.00, // $3.00 per 1M input tokens\n outputPer1M: 15.00, // $15.00 per 1M output tokens\n }\n} as const;\n\nexport function calculateCost(\n model: string,\n promptTokens: number,\n completionTokens: number\n): number {\n const pricing = MODEL_PRICING[model];\n if (!pricing) throw new Error(`Unknown model: ${model}`);\n\n const inputCost = (promptTokens / 1_000_000) * pricing.inputPer1M;\n const outputCost = (completionTokens / 1_000_000) * pricing.outputPer1M;\n\n return inputCost + outputCost;\n}\n```\n\n### Trial Credit Calculations\n\n```typescript\n// How far does $1.00 get you with Haiku?\n\n// Average request: 1000 input tokens, 500 output tokens\nconst avgCost = (1000 / 1_000_000 * 0.25) + (500 / 1_000_000 * 1.25)\n = 0.00025 + 0.000625\n = 0.000875 per request\n\n// $1.00 / $0.000875 ≈ 1,143 requests\n\n// Small request: 500 input, 200 output\nconst smallCost = (500 / 1_000_000 * 0.25) + (200 / 1_000_000 * 1.25)\n = 0.000125 + 0.00025\n = 0.000375 per request\n\n// $1.00 / $0.000375 ≈ 2,667 requests\n\n// Large request: 5000 input, 2000 output\nconst largeCost = (5000 / 1_000_000 * 0.25) + (2000 / 1_000_000 * 1.25)\n = 0.00125 + 0.0025\n = 0.00375 per request\n\n// $1.00 / $0.00375 ≈ 267 requests\n```\n\n**Conclusion**: $1.00 provides approximately 300-2,500 AI requests depending on conversation length. More than sufficient for trial evaluation.\n\n### Security Considerations\n\n**API Key Storage**:\n```yaml\n# site/infra/docker-compose.yml\nservices:\n backend:\n environment:\n - ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key\n secrets:\n - anthropic_api_key\n\nsecrets:\n anthropic_api_key:\n file: ./secrets/anthropic_api_key.txt\n```\n\n**Rate Limiting**:\n```typescript\n// Prevent abuse even with valid JWT\n// Limit: 100 requests per minute per user\nimport rateLimit from 'express-rate-limit';\n\nconst aiProxyLimiter = rateLimit({\n windowMs: 60 * 1000, // 1 minute\n max: 100, // 100 requests per window\n keyGenerator: (req) => req.userId, // Rate limit per user\n handler: (req, res) => {\n res.status(429).json({ error: 'Too many requests, please slow down' });\n }\n});\n\napp.post('/api/ai/chat', authMiddleware, aiProxyLimiter, aiChatHandler);\n```\n\n**Input Validation**:\n```typescript\n// Prevent malicious input\nconst MAX_MESSAGES = 100; // Max messages in conversation\nconst MAX_MESSAGE_LENGTH = 50000; // Max chars per message\n\nfunction validateChatRequest(body: any): void {\n if (!Array.isArray(body.messages)) {\n throw new Error('messages must be an array');\n }\n\n if (body.messages.length > MAX_MESSAGES) {\n throw new Error(`Too many messages (max ${MAX_MESSAGES})`);\n }\n\n for (const msg of body.messages) {\n if (!msg.role || !msg.content) {\n throw new Error('Invalid message format');\n }\n if (msg.content.length > MAX_MESSAGE_LENGTH) {\n throw new Error(`Message too long (max ${MAX_MESSAGE_LENGTH} chars)`);\n }\n }\n}\n```\n\n---\n\n## Database Schema\n\n### Users Table\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email TEXT NOT NULL UNIQUE,\n password_hash TEXT NOT NULL,\n email_verified BOOLEAN DEFAULT FALSE,\n verification_code TEXT,\n verification_code_expires TIMESTAMP,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_users_email ON users(email);\nCREATE INDEX idx_users_verification_code ON users(verification_code);\n```\n\n### Subscriptions Table\n```sql\nCREATE TABLE subscriptions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n\n -- Plan status\n plan TEXT NOT NULL CHECK (plan IN ('trial', 'paid', 'expired')),\n\n -- Trial tracking (credit-based)\n token_credits_remaining NUMERIC(10, 6) DEFAULT 1.000000, -- $1.00 in credits\n token_credits_initial NUMERIC(10, 6) DEFAULT 1.000000,\n total_tokens_used BIGINT DEFAULT 0, -- Aggregate tokens (analytics only)\n total_cost_usd NUMERIC(10, 6) DEFAULT 0.000000, -- Aggregate cost (analytics only)\n trial_started_at TIMESTAMP DEFAULT NOW(),\n trial_ended_at TIMESTAMP,\n\n -- Payment provider data (Stripe/LemonSqueezy/Paddle)\n stripe_customer_id TEXT,\n stripe_subscription_id TEXT,\n subscription_status TEXT CHECK (subscription_status IN ('active', 'canceled', 'past_due', 'incomplete')),\n subscription_period_end TIMESTAMP,\n\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n\n UNIQUE(user_id),\n CHECK (token_credits_remaining >= 0) -- Cannot go negative\n);\n\nCREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);\nCREATE INDEX idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id);\nCREATE INDEX idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);\n```\n\n### Drizzle Schema (TypeScript)\n```typescript\n// src/backend/db/schema.ts\nimport { pgTable, uuid, text, integer, timestamp, boolean, numeric, bigint } from 'drizzle-orm/pg-core';\n\nexport const users = pgTable('users', {\n id: uuid('id').defaultRandom().primaryKey(),\n email: text('email').notNull().unique(),\n passwordHash: text('password_hash').notNull(),\n emailVerified: boolean('email_verified').default(false),\n verificationCode: text('verification_code'),\n verificationCodeExpires: timestamp('verification_code_expires'),\n createdAt: timestamp('created_at').defaultNow(),\n updatedAt: timestamp('updated_at').defaultNow(),\n});\n\nexport const subscriptions = pgTable('subscriptions', {\n id: uuid('id').defaultRandom().primaryKey(),\n userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull().unique(),\n\n // Plan status\n plan: text('plan').notNull(), // 'trial' | 'paid' | 'expired'\n\n // Trial tracking (credit-based)\n tokenCreditsRemaining: numeric('token_credits_remaining', { precision: 10, scale: 6 }).default('1.000000'),\n tokenCreditsInitial: numeric('token_credits_initial', { precision: 10, scale: 6 }).default('1.000000'),\n totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).default(0),\n totalCostUsd: numeric('total_cost_usd', { precision: 10, scale: 6 }).default('0.000000'),\n trialStartedAt: timestamp('trial_started_at').defaultNow(),\n trialEndedAt: timestamp('trial_ended_at'),\n\n // Payment provider data\n stripeCustomerId: text('stripe_customer_id'),\n stripeSubscriptionId: text('stripe_subscription_id'),\n subscriptionStatus: text('subscription_status'), // 'active' | 'canceled' | 'past_due' | 'incomplete'\n subscriptionPeriodEnd: timestamp('subscription_period_end'),\n\n createdAt: timestamp('created_at').defaultNow(),\n updatedAt: timestamp('updated_at').defaultNow(),\n});\n```\n\n### Data Size Estimation\n```\n1 user record: ~500 bytes (email, hash, timestamps)\n1 subscription: ~900 bytes (plan, credits/numeric fields, stripe data)\nTotal per user: ~1.4KB\n\n1,000 users: ~1.4MB\n10,000 users: ~14MB\n100,000 users: ~140MB\n\nDatabase on disk: ~2-3x data size (indexes, overhead)\nRAM requirement: ~100MB per 10k active users\n```\n\n---\n\n## API Endpoints\n\n### Authentication Endpoints\n\n#### POST /api/auth/signup\n```typescript\nRequest:\n{\n email: string,\n password: string\n}\n\nResponse:\n{\n message: \"Verification email sent\",\n userId: string\n}\n\nFlow:\n1. Validate email format & password strength\n2. Check if email already exists\n3. Hash password with bcrypt\n4. Generate 6-digit verification code\n5. Send email with code (expires in 15 minutes)\n6. Create user record (email_verified=false)\n7. Create subscription record (plan='trial', message_count=0)\n```\n\n#### POST /api/auth/verify\n```typescript\nRequest:\n{\n email: string,\n code: string\n}\n\nResponse:\n{\n token: string, // JWT for authenticated requests\n user: {\n id: string,\n email: string\n }\n}\n\nFlow:\n1. Find user by email\n2. Check verification code matches and hasn't expired\n3. Set email_verified=true\n4. Generate JWT token (expires in 30 days)\n5. Return token + user data\n```\n\n#### POST /api/auth/login\n```typescript\nRequest:\n{\n email: string,\n password: string\n}\n\nResponse:\n{\n token: string,\n user: {\n id: string,\n email: string\n }\n}\n\nFlow:\n1. Rate limit: 5 attempts per 15 minutes per IP\n2. Find user by email\n3. Check email_verified=true\n4. Verify password with bcrypt\n5. Generate JWT token\n6. Return token + user data\n```\n\n#### POST /api/auth/resend-verification\n```typescript\nRequest:\n{\n email: string\n}\n\nResponse:\n{\n message: \"Verification email sent\"\n}\n\nFlow:\n1. Find user by email\n2. Check if already verified (return early)\n3. Generate new verification code\n4. Send email\n5. Update user record\n```\n\n---\n\n### Subscription Endpoints\n\n#### POST /api/ai/chat (AI Proxy Endpoint)\n```typescript\nHeaders:\n{\n Authorization: \"Bearer <jwt_token>\"\n}\n\nRequest:\n{\n messages: Array<{role: string, content: string}>,\n model?: string // Optional, defaults to haiku for trial users\n}\n\nResponse:\n{\n response: {\n id: string,\n content: string,\n model: string,\n usage: {\n promptTokens: number,\n completionTokens: number,\n totalTokens: number\n }\n },\n subscription: {\n tokenCreditsRemaining: number,\n totalTokensUsed: number,\n plan: 'trial' | 'paid' | 'expired'\n }\n}\n\nFlow:\n1. Authenticate user from JWT\n2. Check subscription plan:\n - If 'trial': Use Sitegeist API keys, check credits > 0\n - If 'paid': Use user's BYOK or Sitegeist keys (unlimited)\n - If 'expired': Return 402 Payment Required\n3. Forward request to AI provider (Anthropic)\n4. Calculate cost based on usage:\n cost = (promptTokens * promptPrice) + (completionTokens * completionPrice)\n Example: Haiku = $0.25/1M input, $1.25/1M output\n5. Atomic credit deduction (if trial):\n UPDATE subscriptions\n SET token_credits_remaining = token_credits_remaining - $cost,\n total_tokens_used = total_tokens_used + $totalTokens,\n total_cost_usd = total_cost_usd + $cost,\n updated_at = NOW()\n WHERE user_id = $1 AND plan = 'trial'\n RETURNING *\n6. If credits <= 0 after deduction: set plan='expired'\n7. Return AI response + updated subscription status\n```\n\n#### GET /api/subscription/status\n```typescript\nHeaders:\n{\n Authorization: \"Bearer <jwt_token>\"\n}\n\nResponse:\n{\n plan: 'trial' | 'paid' | 'expired',\n tokenCreditsRemaining: number, // Only for trial\n totalTokensUsed: number,\n totalCostUsd: number,\n subscriptionStatus?: 'active' | 'canceled' | 'past_due',\n subscriptionPeriodEnd?: string,\n trialStartedAt: string\n}\n\nFlow:\n1. Authenticate user from JWT\n2. Query subscription by user_id\n3. Return subscription data\n```\n\n#### POST /api/subscription/create-checkout\n```typescript\nHeaders:\n{\n Authorization: \"Bearer <jwt_token>\"\n}\n\nResponse:\n{\n checkoutUrl: string // Stripe checkout session URL\n}\n\nFlow:\n1. Authenticate user from JWT\n2. Get/create Stripe customer:\n - If stripe_customer_id exists, use it\n - Else create customer with user email\n3. Create Stripe checkout session:\n - Price: $5-10/month\n - Success URL: extension://success\n - Cancel URL: extension://cancel\n - Customer email prefilled\n - Metadata: userId\n4. Return checkout URL\n```\n\n---\n\n### Webhook Endpoint\n\n#### POST /webhooks/stripe\n```typescript\nHeaders:\n{\n Stripe-Signature: string // Verify webhook authenticity\n}\n\nBody: (Stripe webhook event)\n\nFlow:\n1. Verify Stripe webhook signature\n2. Handle event types:\n\n checkout.session.completed:\n - Extract customer_id, subscription_id from event\n - Find user by stripe_customer_id\n - Update subscription:\n - plan = 'paid'\n - subscription_status = 'active'\n - stripe_subscription_id = event.subscription\n - subscription_period_end = event.period_end\n\n customer.subscription.updated:\n - Find user by stripe_subscription_id\n - Update subscription_status and period_end\n\n customer.subscription.deleted:\n - Find user by stripe_subscription_id\n - Set plan = 'expired', subscription_status = 'canceled'\n\n invoice.payment_failed:\n - Find user by stripe_subscription_id\n - Set subscription_status = 'past_due'\n\n3. Return 200 OK (Stripe retries on failure)\n```\n\n---\n\n## Payment Flow\n\n### Step-by-Step User Journey\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ 1. User Exhausts $1.00 Trial Credits │\n├─────────────────────────────────────────────────────────────┤\n│ Extension calls: POST /api/ai/chat │\n│ Response: { │\n│ subscription: { │\n│ plan: 'expired', │\n│ tokenCreditsRemaining: 0.000000 │\n│ } │\n│ } │\n│ Extension shows paywall modal │\n└─────────────────────────────────────────────────────────────┘\n ↓\n┌─────────────────────────────────────────────────────────────┐\n│ 2. User Clicks \"Upgrade to Paid Plan\" │\n├─────────────────────────────────────────────────────────────┤\n│ Extension calls: POST /api/subscription/create-checkout │\n│ Server creates Stripe checkout session │\n│ Response: { checkoutUrl: \"https://checkout.stripe.com/...\" }│\n└─────────────────────────────────────────────────────────────┘\n ↓\n┌─────────────────────────────────────────────────────────────┐\n│ 3. Open Checkout in New Tab │\n├─────────────────────────────────────────────────────────────┤\n│ chrome.tabs.create({ url: checkoutUrl }) │\n│ User completes payment in Stripe-hosted checkout │\n│ Stripe redirects to success/cancel URL │\n└─────────────────────────────────────────────────────────────┘\n ↓\n┌─────────────────────────────────────────────────────────────┐\n│ 4. Extension Polls for Status │\n├─────────────────────────────────────────────────────────────┤\n│ Poll every 5 seconds: GET /api/subscription/status │\n│ │\n│ const pollInterval = setInterval(async () => { │\n│ const status = await fetch('/api/subscription/status'); │\n│ if (status.plan === 'paid') { │\n│ clearInterval(pollInterval); │\n│ showSuccessMessage(); │\n│ enableChatInput(); │\n│ } │\n│ }, 5000); │\n│ │\n│ Timeout after 5 minutes (user probably abandoned) │\n└─────────────────────────────────────────────────────────────┘\n ↓\n┌─────────────────────────────────────────────────────────────┐\n│ 5. Stripe Sends Webhook (Parallel to Polling) │\n├─────────────────────────────────────────────────────────────┤\n│ POST /webhooks/stripe │\n│ Event: checkout.session.completed │\n│ │\n│ Server updates database: │\n│ UPDATE subscriptions │\n│ SET plan = 'paid', │\n│ subscription_status = 'active', │\n│ stripe_subscription_id = '...' │\n│ WHERE user_id = ... │\n└─────────────────────────────────────────────────────────────┘\n ↓\n┌─────────────────────────────────────────────────────────────┐\n│ 6. Extension Detects Plan Change │\n├─────────────────────────────────────────────────────────────┤\n│ Next poll returns: { plan: 'paid' } │\n│ Stop polling, show success, resume chat │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Cancellation Flow\n```\nUser cancels in Stripe customer portal\n ↓\nStripe sends webhook: customer.subscription.deleted\n ↓\nServer updates: plan = 'expired', subscription_status = 'canceled'\n ↓\nNext time user opens extension:\n GET /api/subscription/status\n → { plan: 'expired' }\n → Show \"resubscribe\" modal\n```\n\n---\n\n## Implementation Plan\n\n### Phase 1: Local Development Setup (1 day)\n\n**1. Add PostgreSQL to docker-compose**\n```bash\n# Create docker-compose.dev.yml\ncat > site/infra/docker-compose.dev.yml <<EOF\nservices:\n postgres:\n image: postgres:16\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_DB: sitegeist_dev\n POSTGRES_USER: dev\n POSTGRES_PASSWORD: dev\n volumes:\n - postgres_dev_data:/var/lib/postgresql/data\n\nvolumes:\n postgres_dev_data:\nEOF\n\n# Update run.sh dev command\ndocker compose -f infra/docker-compose.dev.yml up -d postgres\n```\n\n**2. Install dependencies**\n```bash\ncd site\nnpm install drizzle-orm pg\nnpm install -D drizzle-kit @types/pg\n```\n\n**3. Setup Drizzle**\n```typescript\n// site/src/backend/db/schema.ts\n// (copy schema from above)\n\n// site/src/backend/db/client.ts\nimport { drizzle } from 'drizzle-orm/node-postgres';\nimport pg from 'pg';\n\nconst pool = new pg.Pool({\n connectionString: process.env.DATABASE_URL ||\n 'postgresql://dev:dev@localhost:5432/sitegeist_dev'\n});\n\nexport const db = drizzle(pool);\n\n// site/drizzle.config.ts\nimport type { Config } from 'drizzle-kit';\n\nexport default {\n schema: './src/backend/db/schema.ts',\n out: './migrations',\n dialect: 'postgresql',\n dbCredentials: {\n url: process.env.DATABASE_URL ||\n 'postgresql://dev:dev@localhost:5432/sitegeist_dev'\n }\n} satisfies Config;\n```\n\n**4. Generate and run migrations**\n```bash\nnpx drizzle-kit generate\nnpx drizzle-kit migrate\n```\n\n---\n\n### Phase 2: Auth Implementation (2 days)\n\n**1. Password hashing utilities**\n```typescript\n// site/src/backend/auth/password.ts\nimport bcrypt from 'bcrypt';\n\nexport async function hashPassword(password: string): Promise<string> {\n return bcrypt.hash(password, 10);\n}\n\nexport async function verifyPassword(\n password: string,\n hash: string\n): Promise<boolean> {\n return bcrypt.compare(password, hash);\n}\n```\n\n**2. JWT utilities**\n```typescript\n// site/src/backend/auth/jwt.ts\nimport jwt from 'jsonwebtoken';\n\nconst JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-prod';\n\nexport function generateToken(userId: string): string {\n return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '30d' });\n}\n\nexport function verifyToken(token: string): { userId: string } | null {\n try {\n return jwt.verify(token, JWT_SECRET) as { userId: string };\n } catch {\n return null;\n }\n}\n```\n\n**3. Auth middleware**\n```typescript\n// site/src/backend/auth/middleware.ts\nimport { Request, Response, NextFunction } from 'express';\nimport { verifyToken } from './jwt.js';\n\nexport function authMiddleware(\n req: Request,\n res: Response,\n next: NextFunction\n) {\n const auth = req.headers.authorization;\n if (!auth?.startsWith('Bearer ')) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n\n const token = auth.slice(7);\n const payload = verifyToken(token);\n\n if (!payload) {\n return res.status(401).json({ error: 'Invalid token' });\n }\n\n // Attach userId to request\n (req as any).userId = payload.userId;\n next();\n}\n```\n\n**4. Email service (verification codes)**\n```typescript\n// site/src/backend/email/service.ts\nimport nodemailer from 'nodemailer';\n\n// Use SMTP service (Sendgrid, Mailgun, AWS SES, etc.)\nconst transporter = nodemailer.createTransport({\n host: process.env.SMTP_HOST,\n port: Number(process.env.SMTP_PORT),\n auth: {\n user: process.env.SMTP_USER,\n pass: process.env.SMTP_PASS\n }\n});\n\nexport async function sendVerificationEmail(\n email: string,\n code: string\n): Promise<void> {\n await transporter.sendMail({\n from: 'noreply@sitegeist.ai',\n to: email,\n subject: 'Verify your Sitegeist account',\n text: `Your verification code is: ${code}\\n\\nThis code expires in 15 minutes.`,\n html: `\n <h1>Welcome to Sitegeist!</h1>\n <p>Your verification code is:</p>\n <h2>${code}</h2>\n <p>This code expires in 15 minutes.</p>\n `\n });\n}\n\nexport function generateVerificationCode(): string {\n return Math.floor(100000 + Math.random() * 900000).toString();\n}\n```\n\n**5. Auth handlers**\n```typescript\n// site/src/backend/handlers/auth.ts\n// Implement signup, verify, login, resend-verification\n// (Use schema from API Endpoints section above)\n```\n\n---\n\n### Phase 3: AI Proxy Implementation (2 days)\n\n**1. Install Anthropic SDK**\n```bash\nnpm install @anthropic-ai/sdk\n```\n\n**2. Token pricing module**\n```typescript\n// site/src/backend/ai/pricing.ts\nexport const MODEL_PRICING = {\n 'claude-3-haiku-20240307': {\n inputPer1M: 0.25,\n outputPer1M: 1.25,\n },\n} as const;\n\nexport function calculateCost(\n model: string,\n promptTokens: number,\n completionTokens: number\n): number {\n const pricing = MODEL_PRICING[model];\n if (!pricing) throw new Error(`Unknown model: ${model}`);\n\n const inputCost = (promptTokens / 1_000_000) * pricing.inputPer1M;\n const outputCost = (completionTokens / 1_000_000) * pricing.outputPer1M;\n\n return inputCost + outputCost;\n}\n```\n\n**3. AI proxy handler**\n```typescript\n// site/src/backend/handlers/ai.ts\nimport Anthropic from '@anthropic-ai/sdk';\nimport { db } from '../db/client.js';\nimport { subscriptions } from '../db/schema.js';\nimport { eq, sql } from 'drizzle-orm';\nimport { calculateCost } from '../ai/pricing.js';\n\nconst anthropic = new Anthropic({\n apiKey: process.env.ANTHROPIC_API_KEY\n});\n\nexport async function handleAiChat(req: any, res: any) {\n const userId = req.userId; // From auth middleware\n const { messages, model = 'claude-3-haiku-20240307' } = req.body;\n\n // Get subscription\n const [subscription] = await db\n .select()\n .from(subscriptions)\n .where(eq(subscriptions.userId, userId));\n\n // Check plan status\n if (subscription.plan === 'expired') {\n return res.status(402).json({ error: 'Trial credits exhausted' });\n }\n\n if (subscription.plan === 'trial' && subscription.tokenCreditsRemaining <= 0) {\n return res.status(402).json({ error: 'Insufficient credits' });\n }\n\n // Forward to Anthropic\n const response = await anthropic.messages.create({\n model,\n messages,\n max_tokens: 4096\n });\n\n // Calculate cost\n const cost = calculateCost(\n model,\n response.usage.input_tokens,\n response.usage.output_tokens\n );\n\n // Deduct credits (atomic) if trial\n if (subscription.plan === 'trial') {\n const [updated] = await db\n .update(subscriptions)\n .set({\n tokenCreditsRemaining: sql`token_credits_remaining - ${cost}`,\n totalTokensUsed: sql`total_tokens_used + ${response.usage.input_tokens + response.usage.output_tokens}`,\n totalCostUsd: sql`total_cost_usd + ${cost}`,\n plan: sql`CASE WHEN token_credits_remaining - ${cost} <= 0 THEN 'expired' ELSE plan END`,\n updatedAt: new Date()\n })\n .where(eq(subscriptions.userId, userId))\n .returning();\n\n return res.json({\n response: {\n id: response.id,\n content: response.content[0].text,\n model: response.model,\n usage: {\n promptTokens: response.usage.input_tokens,\n completionTokens: response.usage.output_tokens,\n totalTokens: response.usage.input_tokens + response.usage.output_tokens\n }\n },\n subscription: {\n tokenCreditsRemaining: Number(updated.tokenCreditsRemaining),\n totalTokensUsed: updated.totalTokensUsed,\n plan: updated.plan\n }\n });\n }\n\n // Paid users: just return response\n return res.json({\n response: {\n id: response.id,\n content: response.content[0].text,\n model: response.model,\n usage: {\n promptTokens: response.usage.input_tokens,\n completionTokens: response.usage.output_tokens,\n totalTokens: response.usage.input_tokens + response.usage.output_tokens\n }\n },\n subscription: {\n plan: subscription.plan\n }\n });\n}\n```\n\n**4. Rate limiting**\n```bash\nnpm install express-rate-limit\n```\n\n```typescript\n// site/src/backend/middleware/rate-limit.ts\nimport rateLimit from 'express-rate-limit';\n\nexport const aiProxyLimiter = rateLimit({\n windowMs: 60 * 1000,\n max: 100,\n keyGenerator: (req: any) => req.userId,\n handler: (req, res) => {\n res.status(429).json({ error: 'Too many requests' });\n }\n});\n```\n\n---\n\n### Phase 4: Subscription & Payments (2 days)\n\n**1. Stripe setup**\n```bash\nnpm install stripe\n```\n\n**2. Stripe client**\n```typescript\n// site/src/backend/payments/stripe.ts\nimport Stripe from 'stripe';\n\nexport const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {\n apiVersion: '2024-11-20.acacia'\n});\n\nexport async function createCheckoutSession(\n userId: string,\n email: string,\n customerId?: string\n): Promise<string> {\n // Create customer if doesn't exist\n if (!customerId) {\n const customer = await stripe.customers.create({ email });\n customerId = customer.id;\n }\n\n // Create checkout session\n const session = await stripe.checkout.sessions.create({\n customer: customerId,\n mode: 'subscription',\n line_items: [{\n price: process.env.STRIPE_PRICE_ID!, // Create in Stripe dashboard\n quantity: 1\n }],\n success_url: 'https://sitegeist.ai/success',\n cancel_url: 'https://sitegeist.ai/cancel',\n metadata: { userId }\n });\n\n return session.url!;\n}\n```\n\n**3. Webhook handler**\n```typescript\n// site/src/backend/payments/webhook.ts\nimport { stripe } from './stripe.js';\nimport { db } from '../db/client.js';\nimport { subscriptions } from '../db/schema.js';\nimport { eq } from 'drizzle-orm';\n\nexport async function handleStripeWebhook(\n signature: string,\n body: Buffer\n): Promise<void> {\n const event = stripe.webhooks.constructEvent(\n body,\n signature,\n process.env.STRIPE_WEBHOOK_SECRET!\n );\n\n switch (event.type) {\n case 'checkout.session.completed':\n const session = event.data.object;\n await db.update(subscriptions)\n .set({\n plan: 'paid',\n subscriptionStatus: 'active',\n stripeCustomerId: session.customer as string,\n stripeSubscriptionId: session.subscription as string,\n subscriptionPeriodEnd: new Date(session.expires_at * 1000),\n updatedAt: new Date()\n })\n .where(eq(subscriptions.stripeCustomerId, session.customer as string));\n break;\n\n case 'customer.subscription.deleted':\n const subscription = event.data.object;\n await db.update(subscriptions)\n .set({\n plan: 'expired',\n subscriptionStatus: 'canceled',\n updatedAt: new Date()\n })\n .where(eq(subscriptions.stripeSubscriptionId, subscription.id));\n break;\n\n // Handle other events...\n }\n}\n```\n\n**4. Subscription handlers**\n```typescript\n// site/src/backend/handlers/subscription.ts\n// Implement /subscription/status, /subscription/create-checkout\n// (Use schema from API Endpoints section above)\n```\n\n---\n\n### Phase 5: Production Deployment (1 day)\n\n**1. Update production docker-compose.yml**\n```yaml\n# site/infra/docker-compose.yml\nservices:\n postgres:\n image: postgres:16\n restart: unless-stopped\n environment:\n POSTGRES_DB: sitegeist\n POSTGRES_USER: sitegeist\n POSTGRES_PASSWORD_FILE: /run/secrets/db_password\n volumes:\n - postgres_data:/var/lib/postgresql/data\n secrets:\n - db_password\n networks:\n - internal\n\n web:\n # ... existing config\n\n backend:\n # ... existing config\n environment:\n - DATABASE_URL=postgresql://sitegeist:${DB_PASSWORD}@postgres:5432/sitegeist\n - JWT_SECRET_FILE=/run/secrets/jwt_secret\n - ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key\n - STRIPE_SECRET_KEY_FILE=/run/secrets/stripe_secret\n - STRIPE_WEBHOOK_SECRET_FILE=/run/secrets/stripe_webhook\n secrets:\n - jwt_secret\n - anthropic_api_key\n - stripe_secret\n - stripe_webhook\n\nsecrets:\n db_password:\n file: ./secrets/db_password.txt\n jwt_secret:\n file: ./secrets/jwt_secret.txt\n anthropic_api_key:\n file: ./secrets/anthropic_api_key.txt\n stripe_secret:\n file: ./secrets/stripe_secret.txt\n stripe_webhook:\n file: ./secrets/stripe_webhook.txt\n\nvolumes:\n postgres_data:\n```\n\n**2. Create secrets on server**\n```bash\nssh slayer.marioslab.io\n\ncd /home/badlogic/sitegeist.ai/infra\nmkdir -p secrets\n\n# Generate secure passwords\nopenssl rand -base64 32 > secrets/db_password.txt\nopenssl rand -base64 64 > secrets/jwt_secret.txt\n\n# Add API keys\necho \"sk-ant-...\" > secrets/anthropic_api_key.txt # From Anthropic Console\necho \"sk_live_...\" > secrets/stripe_secret.txt # From Stripe dashboard\necho \"whsec_...\" > secrets/stripe_webhook.txt # From Stripe webhook config\n\nchmod 600 secrets/*\n```\n\n**3. Configure Stripe webhook**\n```bash\n# In Stripe dashboard:\n# 1. Go to Developers → Webhooks\n# 2. Add endpoint: https://sitegeist.ai/webhooks/stripe\n# 3. Select events:\n# - checkout.session.completed\n# - customer.subscription.updated\n# - customer.subscription.deleted\n# - invoice.payment_failed\n# 4. Copy webhook signing secret to secrets/stripe_webhook.txt\n```\n\n**4. Deploy**\n```bash\n# From local machine\ncd site\n./run.sh deploy\n\n# On server, migrations run automatically on first start\n# Or manually:\nssh slayer.marioslab.io\ncd /home/badlogic/sitegeist.ai\ndocker compose -f infra/docker-compose.yml exec backend \\\n npx drizzle-kit migrate\n```\n\n**5. Setup automated backups**\n```bash\n# On slayer.marioslab.io\n# Create backup script\ncat > /home/badlogic/sitegeist.ai/backup.sh <<'EOF'\n#!/bin/bash\nBACKUP_DIR=/home/badlogic/backups/sitegeist\nDATE=$(date +%Y%m%d_%H%M%S)\n\nmkdir -p $BACKUP_DIR\n\n# Backup PostgreSQL\ndocker compose -f /home/badlogic/sitegeist.ai/infra/docker-compose.yml \\\n exec -T postgres pg_dump -U sitegeist sitegeist \\\n | gzip > $BACKUP_DIR/db_$DATE.sql.gz\n\n# Keep last 30 days\nfind $BACKUP_DIR -type f -mtime +30 -delete\nEOF\n\nchmod +x /home/badlogic/sitegeist.ai/backup.sh\n\n# Add to crontab (daily at 3am)\ncrontab -e\n# Add: 0 3 * * * /home/badlogic/sitegeist.ai/backup.sh\n```\n\n---\n\n### Phase 6: Extension Integration (2 days)\n\n**1. Add auth state to extension**\n```typescript\n// src/storage/auth.ts\nimport { Store } from './store.js';\n\nexport interface AuthState {\n token: string | null;\n userId: string | null;\n email: string | null;\n}\n\nexport const authStore = new Store<AuthState>('auth', {\n token: null,\n userId: null,\n email: null\n});\n```\n\n**2. API client**\n```typescript\n// src/api/client.ts\nconst API_BASE = 'https://sitegeist.ai/api';\n\nexport class ApiClient {\n private token: string | null = null;\n\n setToken(token: string) {\n this.token = token;\n }\n\n private async request(path: string, options: RequestInit = {}) {\n const headers = new Headers(options.headers);\n\n if (this.token) {\n headers.set('Authorization', `Bearer ${this.token}`);\n }\n\n const response = await fetch(`${API_BASE}${path}`, {\n ...options,\n headers\n });\n\n if (!response.ok) {\n throw new Error(await response.text());\n }\n\n return response.json();\n }\n\n // Auth methods\n async signup(email: string, password: string) {\n return this.request('/auth/signup', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password })\n });\n }\n\n async verify(email: string, code: string) {\n return this.request('/auth/verify', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, code })\n });\n }\n\n async login(email: string, password: string) {\n return this.request('/auth/login', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password })\n });\n }\n\n // AI methods\n async chat(messages: Array<{role: string, content: string}>) {\n return this.request('/ai/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ messages, model: 'claude-3-haiku-20240307' })\n });\n }\n\n // Subscription methods\n async getSubscriptionStatus() {\n return this.request('/subscription/status');\n }\n\n async createCheckout() {\n return this.request('/subscription/create-checkout', {\n method: 'POST'\n });\n }\n}\n\nexport const api = new ApiClient();\n```\n\n**3. AI integration (replace existing AI provider calls)**\n```typescript\n// In src/sidepanel.ts or wherever AI requests are made:\nimport { api } from './api/client.js';\n\nasync function sendMessage(messages: Array<{role: string, content: string}>) {\n try {\n // Call backend AI proxy instead of direct Anthropic API\n const result = await api.chat(messages);\n\n // Check if trial expired\n if (result.subscription.plan === 'expired') {\n showPaywallDialog();\n return null;\n }\n\n // Update UI with remaining credits (for trial users)\n if (result.subscription.plan === 'trial') {\n updateCreditsDisplay(result.subscription.tokenCreditsRemaining);\n }\n\n return result.response.content;\n } catch (error) {\n if (error.message.includes('402')) {\n // Payment required\n showPaywallDialog();\n return null;\n }\n throw error;\n }\n}\n```\n\n**4. Credits display component**\n```typescript\n// src/components/credits-display.ts\nexport function updateCreditsDisplay(creditsRemaining: number) {\n const percentage = (creditsRemaining / 1.0) * 100;\n const element = document.getElementById('trial-credits');\n\n if (element) {\n element.textContent = `Trial: $${creditsRemaining.toFixed(2)} remaining`;\n\n // Visual warning when low\n if (percentage < 20) {\n element.classList.add('low-credits');\n }\n }\n}\n```\n\n**5. Paywall dialog**\n```typescript\n// src/dialogs/paywall-dialog.ts\nexport class PaywallDialog {\n static async show() {\n const result = await showDialog({\n title: 'Trial Credits Exhausted',\n message: `You've used your $1.00 trial credits.\n Upgrade to continue using Sitegeist with your own API keys.`,\n buttons: [\n { label: 'Upgrade ($9/month)', value: 'upgrade' },\n { label: 'Not Now', value: 'cancel' }\n ]\n });\n\n if (result === 'upgrade') {\n await this.handleUpgrade();\n }\n }\n\n private static async handleUpgrade() {\n // Get checkout URL\n const { checkoutUrl } = await api.createCheckout();\n\n // Open in new tab\n chrome.tabs.create({ url: checkoutUrl });\n\n // Poll for status\n const pollInterval = setInterval(async () => {\n const status = await api.getSubscriptionStatus();\n\n if (status.plan === 'paid') {\n clearInterval(pollInterval);\n showSuccessToast('Subscription activated! 🎉');\n location.reload(); // Refresh UI\n }\n }, 5000);\n\n // Timeout after 5 minutes\n setTimeout(() => {\n clearInterval(pollInterval);\n }, 5 * 60 * 1000);\n }\n}\n```\n\n---\n\n## Monitoring & Maintenance\n\n### Health Checks\n```typescript\n// site/src/backend/handlers/health.ts\nexport async function health() {\n // Check database connection\n const dbOk = await db.select().from(users).limit(1)\n .then(() => true)\n .catch(() => false);\n\n return {\n status: dbOk ? 'healthy' : 'unhealthy',\n database: dbOk ? 'connected' : 'disconnected',\n timestamp: new Date().toISOString()\n };\n}\n```\n\n### Monitoring Queries\n```sql\n-- Active subscriptions\nSELECT plan, COUNT(*)\nFROM subscriptions\nGROUP BY plan;\n\n-- Trial users with low credits (< $0.20 remaining)\nSELECT u.email,\n s.token_credits_remaining,\n s.total_tokens_used,\n s.total_cost_usd,\n s.trial_started_at\nFROM users u\nJOIN subscriptions s ON u.id = s.user_id\nWHERE s.plan = 'trial'\n AND s.token_credits_remaining < 0.20\nORDER BY s.token_credits_remaining ASC;\n\n-- Trial usage statistics\nSELECT\n AVG(total_cost_usd) as avg_trial_cost,\n AVG(total_tokens_used) as avg_tokens_used,\n MAX(total_cost_usd) as max_trial_cost,\n COUNT(*) as total_trials\nFROM subscriptions\nWHERE plan IN ('trial', 'expired');\n\n-- Conversion rate\nSELECT\n COUNT(CASE WHEN plan = 'paid' THEN 1 END) as paid_users,\n COUNT(CASE WHEN plan IN ('trial', 'expired') THEN 1 END) as trial_users,\n ROUND(\n COUNT(CASE WHEN plan = 'paid' THEN 1 END)::numeric /\n NULLIF(COUNT(*), 0) * 100,\n 2\n ) as conversion_rate_pct\nFROM subscriptions;\n\n-- Revenue (active paid subscriptions)\nSELECT COUNT(*) as paid_users,\n COUNT(*) * 9 as monthly_revenue_usd\nFROM subscriptions\nWHERE plan = 'paid' AND subscription_status = 'active';\n\n-- Daily trial costs\nSELECT\n DATE(trial_started_at) as date,\n COUNT(*) as new_trials,\n COUNT(*) * 1.00 as trial_cost_usd\nFROM subscriptions\nWHERE trial_started_at > NOW() - INTERVAL '30 days'\nGROUP BY DATE(trial_started_at)\nORDER BY date DESC;\n```\n\n---\n\n## Security Considerations\n\n### Password Requirements\n```typescript\nfunction validatePassword(password: string): boolean {\n return password.length >= 8 &&\n /[A-Z]/.test(password) &&\n /[a-z]/.test(password) &&\n /[0-9]/.test(password);\n}\n```\n\n### Rate Limiting\n```typescript\n// Already implemented in server.ts for /api/login\n// Consider adding to other auth endpoints:\n// - /auth/signup: 3 per hour per IP\n// - /auth/verify: 5 per hour per email\n// - /auth/resend-verification: 3 per hour per email\n```\n\n### Database Connection Pooling\n```typescript\n// site/src/backend/db/client.ts\nconst pool = new pg.Pool({\n connectionString: process.env.DATABASE_URL,\n max: 20, // Maximum connections\n idleTimeoutMillis: 30000,\n connectionTimeoutMillis: 2000,\n});\n```\n\n### SQL Injection Prevention\n```typescript\n// Drizzle ORM automatically parameterizes queries\n// Never use raw SQL with user input:\n\n// ❌ BAD\ndb.execute(sql.raw(`SELECT * FROM users WHERE email = '${userEmail}'`));\n\n// ✅ GOOD\ndb.select().from(users).where(eq(users.email, userEmail));\n```\n\n---\n\n## Cost Analysis\n\n### Current Infrastructure (slayer.marioslab.io)\n```\nServer Cost: Already paid (existing server)\nPostgreSQL: $0 (runs on existing server)\nAdditional RAM: ~500MB (negligible from 45GB available)\nAdditional CPU: <1% of one core (negligible from 32 cores)\n\nTotal Cost: $0/month\n```\n\n### External Services\n```\nEmail (Required):\n- Sendgrid: $15/month (40k emails) ✅\n- Mailgun: $15/month (50k emails)\n- AWS SES: $0.10/1000 emails (~$1/month for 10k users)\n\nPayments (Required):\n- Stripe: 2.9% + $0.30 per transaction ✅ Most popular\n- Paddle: 5% + $0.50 per transaction (better EU support)\n- LemonSqueezy: 5% per transaction\n\nDomain (Existing):\n- sitegeist.ai: Already registered\n\nSSL Certificate:\n- $0 (Caddy handles Let's Encrypt automatically)\n```\n\n### Break-Even Analysis\n\n**Cost Per Trial User**:\n```\nTrial credit provided: $1.00 (Anthropic API costs)\nEmail verification: $0.001 (AWS SES)\n---------------------------------\nTotal cost per trial: $1.001\n\nAssumption: 10% conversion rate (trial → paid)\nCost to acquire 1 paid user: $1.001 × 10 = $10.01\n```\n\n**Monthly Revenue Per Paid User**:\n```\nAssumptions:\n- Subscription: $9/month\n- Stripe fees: 2.9% + $0.30 = $0.56 per transaction\n- Email: $0.10 per user/month (AWS SES)\n- AI costs: $0 (users provide own API keys post-payment)\n\nRevenue per user: $9.00\nStripe fees: -$0.56\nEmail costs: -$0.10\n---------------------------------\nNet per user: $8.34/month\n```\n\n**Payback Period & Profitability**:\n```\nAcquisition cost: $10.01 (one-time)\nMonthly profit: $8.34\n\nPayback period: $10.01 / $8.34 = 1.2 months\nLifetime value (12mo): $8.34 × 12 = $100.08\n\nProfit after 1 year: $100.08 - $10.01 = $90.07 per user\n```\n\n**Break-Even with Fixed Costs**:\n```\nFixed costs: $15/month (SendGrid email service)\nMonthly profit: $8.34 per paid user\n\nBreak-even: 2 paid users ($16.68/month > $15)\n\nWith 100 paid users: $834/month - $15 = $819/month profit\nWith 1000 paid users: $8,340/month - $15 = $8,325/month profit\n```\n\n**Trial User Cost Scenarios**:\n```\nScenario 1: 1,000 trials/month, 10% conversion\n- Trial costs: 1,000 × $1.00 = $1,000\n- Paid users: 100\n- Monthly revenue: 100 × $8.34 = $834\n- Net: $834 - $1,000 = -$166 (loss in first month)\n- Month 2+ (assuming no new trials): $834/month profit\n- Break-even: Month 1.2\n\nScenario 2: 10,000 trials/month, 10% conversion\n- Trial costs: 10,000 × $1.00 = $10,000\n- Paid users: 1,000\n- Monthly revenue: 1,000 × $8.34 = $8,340\n- Net: $8,340 - $10,000 = -$1,660 (loss in first month)\n- Month 2+: $8,340/month profit\n- Break-even: Month 1.2\n\nKey insight: Trial costs are one-time per user, subscription revenue is recurring.\nAfter payback period, each user generates $8.34/month profit indefinitely.\n```\n\n---\n\n## Risk Assessment\n\n### Technical Risks\n\n**1. Database Corruption**\n- Risk: Medium\n- Impact: Critical\n- Mitigation: Daily automated backups + weekly manual verification\n\n**2. Payment Webhook Failures**\n- Risk: Low (Stripe retries)\n- Impact: High (lost revenue)\n- Mitigation: Webhook logging + manual reconciliation script\n\n**3. Race Conditions in Credit Deduction**\n- Risk: Low (PostgreSQL ACID guarantees)\n- Impact: Medium (incorrect balances, users get free credits)\n- Mitigation: Use atomic SQL updates with CHECK constraint (already in design)\n\n**4. AI Proxy API Key Exposure**\n- Risk: Medium\n- Impact: Critical (API key theft = unlimited usage on your account)\n- Mitigation:\n - Store keys in Docker secrets (not environment variables)\n - Rate limiting per user (100 req/min)\n - Monitor Anthropic usage dashboard for anomalies\n - Rotate keys if compromised\n\n**5. Trial Credit Abuse**\n- Risk: High (users create multiple accounts for free credits)\n- Impact: Medium ($1 per abuser, but can add up)\n- Mitigation:\n - Email verification required\n - Rate limit signups per IP (3 per hour)\n - Monitor for patterns (same payment method, device fingerprinting)\n - Consider captcha for signup\n\n**6. Anthropic API Downtime**\n- Risk: Low (Anthropic SLA: 99.9%)\n- Impact: High (trial users can't use extension at all)\n- Mitigation:\n - Graceful error handling in extension\n - Show clear error message\n - Don't deduct credits on failed requests\n - Consider fallback to direct BYOK mode\n\n**7. Server Downtime**\n- Risk: Low (97+ day uptime)\n- Impact: High (can't verify subscriptions OR proxy AI requests)\n- Mitigation: Health checks + monitoring + redundancy planning\n\n### Business Risks\n\n**1. Subscription Abandonment**\n- Risk: High (payment flow requires leaving extension)\n- Impact: High (lost conversions)\n- Mitigation:\n - Clear messaging in paywall\n - Email reminders for abandoned checkouts\n - Simple re-entry flow\n - Show value prop: \"After payment, use your own API keys (no usage limits)\"\n\n**2. Trial Costs Exceeding Revenue**\n- Risk: Medium (if conversion rate is very low)\n- Impact: High (negative cash flow)\n- Mitigation:\n - Monitor conversion rate closely\n - Target: 10% conversion minimum\n - If <5% conversion after 1000 trials, re-evaluate trial amount or model\n - Payback period is only 1.2 months, so sustainable with normal churn\n\n**3. High Trial Usage (Power Users)**\n- Risk: Medium (users maximize their $1 credit)\n- Impact: Low ($1 is capped cost per user)\n- Mitigation:\n - Monitor for outlier usage patterns\n - Rate limiting prevents abuse (100 req/min)\n - Max $1 loss per trial user regardless of usage\n\n---\n\n## Next Steps\n\n1. **Review this document** with another LLM or technical reviewer\n2. **Decide on payment provider** (Stripe recommended)\n3. **Choose email service** (AWS SES for cost, SendGrid for simplicity)\n4. **Set up development environment** (Phase 1)\n5. **Implement auth system** (Phase 2)\n6. **Integrate Stripe** (Phase 3)\n7. **Deploy to production** (Phase 4)\n8. **Update extension** (Phase 5)\n9. **Test end-to-end flow** with test mode Stripe\n10. **Launch** 🚀\n\n---\n\n## Questions for Review\n\n### Trial & Credits\n1. **Trial Amount:** Is $1.00 the right amount? (Provides ~300-2,500 requests with Haiku)\n2. **Trial Abuse:** How to prevent users from creating multiple accounts to get more free credits?\n - Email verification already in place\n - Consider: IP-based rate limiting on signups\n - Consider: Device fingerprinting (more complex)\n3. **Credit Display:** Show remaining credits in extension UI? Just balance or usage history?\n4. **Grace Period:** Allow users to finish current session after credits exhausted? (Currently yes if session started)\n\n### Payments & Pricing\n5. **Payment Provider:** Stripe (most popular) vs Paddle (EU-friendly, higher fees)?\n6. **Pricing:** $5-10/month - what's the target price?\n - Note: After payment, users use their own API keys (BYOK) - no ongoing AI costs\n7. **Subscription Model:** Monthly only, or also offer annual discount?\n\n### Technical\n8. **Email Service:** AWS SES (cheapest) vs SendGrid (simpler) vs Mailgun?\n9. **AI Model Choice:** Haiku only for trial, or allow users to choose? (Sonnet is 12x more expensive)\n10. **Rate Limiting:** 100 requests/minute per user - is this sufficient?\n11. **Max Token Limits:** 4096 max_tokens per request - adjust this?\n\n### Legal & Privacy\n12. **Data Retention:** Keep expired trial users in database? For how long?\n13. **GDPR:** Need user data export/deletion endpoints? (Probably yes for EU)\n14. **Terms of Service:** Trial users agree that Sitegeist proxies their requests through Anthropic?\n15. **Privacy Policy:** Clarify that message content is never stored, only aggregate token counts\n\n### Business\n16. **Conversion Strategy:** What happens when trial expires?\n - Current: Hard paywall, can't create new sessions\n - Alternative: Allow BYOK immediately without payment?\n17. **Trial Reset:** Can users who expired trial get more credits? (Probably no)\n18. **Refunds:** Stripe subscription refund policy?\n\n---\n\n**End of Document**\n"}}}} |