Files

1 line
60 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}