# Turnstile Multi-Widget Pool Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Support 300+ custom domains by distributing them across multiple Cloudflare Turnstile widgets (20 widgets × 15 hostnames each).

**Architecture:** Create a widget pool managed in the database. Each custom domain is assigned to a widget with available capacity. The client receives the correct sitekey via server props, and verification uses the corresponding secret key looked up by domain.

**Tech Stack:** Prisma 7 (PostgreSQL), Next.js 16 App Router, Cloudflare Turnstile API, AES-256-GCM encryption for secrets

---

## Prerequisites

Before starting, ensure you have:

- [ ] Access to Cloudflare dashboard to create Turnstile widgets
- [ ] The existing primary widget's sitekey and secret from env vars
- [ ] Generated a 32-byte encryption key: `openssl rand -hex 32`

## Task Overview

1. Database schema for TurnstileWidget model
2. Encryption utilities for secret storage
3. Widget pool management library
4. API endpoint for sitekey lookup
5. Update verification to use domain-specific secrets
6. Update domain activation to assign widgets
7. Update domain deletion to release widgets
8. Client-side integration (server props)
9. Migration script for existing custom domains
10. Widget creation script

---

### Task 1: Add TurnstileWidget Model to Prisma Schema

**Files:**

- Modify: `prisma/schema.prisma`

**Step 1: Add TurnstileWidget model and Dealer relation**

Add to `prisma/schema.prisma` after the existing models:

```prisma
model TurnstileWidget {
  id                  String   @id @default(cuid())

  // Cloudflare identifiers
  cloudflareWidgetId  String   @unique  // Widget ID in Cloudflare dashboard
  sitekey             String   @unique  // Public sitekey for client-side
  secretKeyEncrypted  String             // AES-256-GCM encrypted secret key

  // Pool management
  name                String             // e.g., "primary", "pool-1", "pool-2"
  isPrimary           Boolean  @default(false)  // Primary widget has base domains
  hostnameCount       Int      @default(0)      // Current count (denormalized)
  maxHostnames        Int      @default(15)     // Configurable limit per widget

  // Status
  isActive            Boolean  @default(true)

  // Relations
  dealers             Dealer[]

  createdAt           DateTime @default(now())
  updatedAt           DateTime @updatedAt

  @@index([isActive, hostnameCount])
}
```

**Step 2: Add turnstileWidget relation to Dealer model**

Find the Dealer model and add after `codeSnippet`:

```prisma
  turnstileWidgetId    String?
  turnstileWidget      TurnstileWidget? @relation(fields: [turnstileWidgetId], references: [id])
```

**Step 3: Sync database schema**

Run: `npm run sync-db`

Expected: Schema synced successfully, new table created

**Step 4: Verify migration**

Run: `npx prisma studio`

Expected: TurnstileWidget table visible in Prisma Studio

**Step 5: Commit**

```bash
git add prisma/schema.prisma
git commit -m "feat(turnstile): add TurnstileWidget model for multi-widget pool"
```

---

### Task 2: Create Encryption Utilities for Secret Storage

**Files:**

- Create: `lib/turnstile-secrets.ts`
- Create: `lib/__tests__/turnstile-secrets.test.ts`

**Step 1: Write the failing tests**

Create `lib/__tests__/turnstile-secrets.test.ts`:

```typescript
import { encryptSecret, decryptSecret } from '../turnstile-secrets';

describe('turnstile-secrets', () => {
  // Store original env
  const originalEnv = process.env;

  beforeEach(() => {
    // Set a test encryption key (32 bytes = 64 hex chars)
    process.env.TURNSTILE_ENCRYPTION_KEY = 'a'.repeat(64);
  });

  afterEach(() => {
    process.env = originalEnv;
  });

  describe('encryptSecret', () => {
    it('should encrypt a secret and return a colon-separated string', () => {
      const secret = 'test-secret-key-12345';
      const encrypted = encryptSecret(secret);

      // Format: iv:authTag:ciphertext (all hex)
      const parts = encrypted.split(':');
      expect(parts).toHaveLength(3);
      expect(parts[0]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes IV = 32 hex
      expect(parts[1]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes auth tag = 32 hex
      expect(parts[2]).toMatch(/^[a-f0-9]+$/); // ciphertext varies
    });

    it('should produce different ciphertext each time (random IV)', () => {
      const secret = 'same-secret';
      const encrypted1 = encryptSecret(secret);
      const encrypted2 = encryptSecret(secret);

      expect(encrypted1).not.toBe(encrypted2);
    });

    it('should throw if encryption key not configured', () => {
      delete process.env.TURNSTILE_ENCRYPTION_KEY;

      expect(() => encryptSecret('secret')).toThrow('TURNSTILE_ENCRYPTION_KEY not configured');
    });

    it('should throw if encryption key is wrong length', () => {
      process.env.TURNSTILE_ENCRYPTION_KEY = 'tooshort';

      expect(() => encryptSecret('secret')).toThrow(
        'TURNSTILE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)'
      );
    });
  });

  describe('decryptSecret', () => {
    it('should decrypt an encrypted secret back to original', () => {
      const original = 'my-super-secret-turnstile-key';
      const encrypted = encryptSecret(original);
      const decrypted = decryptSecret(encrypted);

      expect(decrypted).toBe(original);
    });

    it('should handle secrets with special characters', () => {
      const original = 'secret-with-$pecial_chars!@#%^&*()';
      const encrypted = encryptSecret(original);
      const decrypted = decryptSecret(encrypted);

      expect(decrypted).toBe(original);
    });

    it('should throw on tampered ciphertext', () => {
      const encrypted = encryptSecret('secret');
      const parts = encrypted.split(':');
      // Tamper with ciphertext
      parts[2] = 'ff'.repeat(parts[2].length / 2);
      const tampered = parts.join(':');

      expect(() => decryptSecret(tampered)).toThrow();
    });

    it('should throw on invalid format', () => {
      expect(() => decryptSecret('invalid')).toThrow('Invalid encrypted secret format');
    });
  });

  describe('round-trip encryption', () => {
    it('should handle empty string', () => {
      const original = '';
      const encrypted = encryptSecret(original);
      const decrypted = decryptSecret(encrypted);

      expect(decrypted).toBe(original);
    });

    it('should handle long secrets', () => {
      const original = 'x'.repeat(1000);
      const encrypted = encryptSecret(original);
      const decrypted = decryptSecret(encrypted);

      expect(decrypted).toBe(original);
    });
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- lib/__tests__/turnstile-secrets.test.ts`

Expected: FAIL - Cannot find module '../turnstile-secrets'

**Step 3: Write the implementation**

Create `lib/turnstile-secrets.ts`:

```typescript
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;

/**
 * Get the encryption key from environment.
 * Validates that it's the correct length.
 */
function getEncryptionKey(): Buffer {
  const keyHex = process.env.TURNSTILE_ENCRYPTION_KEY;

  if (!keyHex) {
    throw new Error('TURNSTILE_ENCRYPTION_KEY not configured');
  }

  if (keyHex.length !== 64) {
    throw new Error('TURNSTILE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
  }

  return Buffer.from(keyHex, 'hex');
}

/**
 * Encrypt a Turnstile secret key for database storage.
 * Uses AES-256-GCM with a random IV for each encryption.
 *
 * @param secret - The plaintext secret key
 * @returns Encrypted string in format: iv:authTag:ciphertext (all hex)
 */
export function encryptSecret(secret: string): string {
  const key = getEncryptionKey();
  const iv = randomBytes(IV_LENGTH);

  const cipher = createCipheriv(ALGORITHM, key, iv);
  const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();

  return [iv.toString('hex'), authTag.toString('hex'), encrypted.toString('hex')].join(':');
}

/**
 * Decrypt a Turnstile secret key from database storage.
 *
 * @param encryptedSecret - Encrypted string from encryptSecret()
 * @returns The original plaintext secret key
 * @throws Error if decryption fails (wrong key, tampered data, etc.)
 */
export function decryptSecret(encryptedSecret: string): string {
  const parts = encryptedSecret.split(':');

  if (parts.length !== 3) {
    throw new Error('Invalid encrypted secret format');
  }

  const [ivHex, authTagHex, ciphertextHex] = parts;
  const key = getEncryptionKey();

  const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
  decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));

  const decrypted = Buffer.concat([
    decipher.update(Buffer.from(ciphertextHex, 'hex')),
    decipher.final(),
  ]);

  return decrypted.toString('utf8');
}
```

**Step 4: Run tests to verify they pass**

Run: `npm test -- lib/__tests__/turnstile-secrets.test.ts`

Expected: All 9 tests PASS

**Step 5: Commit**

```bash
git add lib/turnstile-secrets.ts lib/__tests__/turnstile-secrets.test.ts
git commit -m "feat(turnstile): add encryption utilities for secret storage"
```

---

### Task 3: Create Widget Pool Management Library

**Files:**

- Create: `lib/turnstile-pool.ts`
- Create: `lib/__tests__/turnstile-pool.test.ts`

**Step 1: Write the failing tests**

Create `lib/__tests__/turnstile-pool.test.ts`:

```typescript
import { prismaMock } from '@/lib/__mocks__/prisma';
import { findAvailableWidget, getWidgetForDomain, WidgetInfo } from '../turnstile-pool';

// Mock the secrets module
jest.mock('../turnstile-secrets', () => ({
  decryptSecret: jest.fn((encrypted: string) => `decrypted-${encrypted}`),
}));

describe('turnstile-pool', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('findAvailableWidget', () => {
    it('should return widget with available capacity', async () => {
      const mockWidget = {
        id: 'widget-1',
        name: 'pool-1',
        hostnameCount: 10,
        maxHostnames: 15,
        isActive: true,
        isPrimary: false,
      };

      prismaMock.turnstileWidget.findFirst.mockResolvedValue(mockWidget as any);

      const result = await findAvailableWidget();

      expect(result).toBe('widget-1');
      expect(prismaMock.turnstileWidget.findFirst).toHaveBeenCalledWith({
        where: {
          isActive: true,
          hostnameCount: { lt: 15 },
          isPrimary: false,
        },
        orderBy: { hostnameCount: 'desc' },
      });
    });

    it('should throw when pool is exhausted', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);

      await expect(findAvailableWidget()).rejects.toThrow('Turnstile widget pool exhausted');
    });

    it('should not return primary widget', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);

      await expect(findAvailableWidget()).rejects.toThrow();

      expect(prismaMock.turnstileWidget.findFirst).toHaveBeenCalledWith(
        expect.objectContaining({
          where: expect.objectContaining({ isPrimary: false }),
        })
      );
    });
  });

  describe('getWidgetForDomain', () => {
    const mockPrimaryWidget = {
      id: 'primary-widget',
      sitekey: 'primary-sitekey',
      secretKeyEncrypted: 'primary-secret-encrypted',
      isPrimary: true,
      isActive: true,
    };

    it('should return primary widget for base domain', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(mockPrimaryWidget as any);

      const result = await getWidgetForDomain('myamsoil.com');

      expect(result).toEqual({
        sitekey: 'primary-sitekey',
        secretKey: 'decrypted-primary-secret-encrypted',
      });
      expect(prismaMock.turnstileWidget.findFirst).toHaveBeenCalledWith({
        where: { isPrimary: true, isActive: true },
      });
    });

    it('should return primary widget for subdomain of base domain', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(mockPrimaryWidget as any);

      const result = await getWidgetForDomain('dealer123.myamsoil.com');

      expect(result).toEqual({
        sitekey: 'primary-sitekey',
        secretKey: 'decrypted-primary-secret-encrypted',
      });
    });

    it('should return primary widget for myamsoil.ca', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(mockPrimaryWidget as any);

      const result = await getWidgetForDomain('dealer.myamsoil.ca');

      expect(result?.sitekey).toBe('primary-sitekey');
    });

    it('should return assigned widget for custom domain', async () => {
      const mockDealer = {
        id: 'dealer-1',
        turnstileWidget: {
          id: 'pool-widget',
          sitekey: 'pool-sitekey',
          secretKeyEncrypted: 'pool-secret-encrypted',
        },
      };

      // First call for primary widget check returns null (not a base domain)
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);
      prismaMock.dealer.findFirst.mockResolvedValue(mockDealer as any);

      const result = await getWidgetForDomain('bobsoil.com');

      expect(result).toEqual({
        sitekey: 'pool-sitekey',
        secretKey: 'decrypted-pool-secret-encrypted',
      });
      expect(prismaMock.dealer.findFirst).toHaveBeenCalledWith({
        where: { customDomain: 'bobsoil.com', customDomainStatus: 'active' },
        include: { turnstileWidget: true },
      });
    });

    it('should return null for unknown custom domain', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);
      prismaMock.dealer.findFirst.mockResolvedValue(null);

      const result = await getWidgetForDomain('unknown.com');

      expect(result).toBeNull();
    });

    it('should return null for custom domain without widget assigned', async () => {
      const mockDealer = {
        id: 'dealer-1',
        turnstileWidget: null,
      };

      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);
      prismaMock.dealer.findFirst.mockResolvedValue(mockDealer as any);

      const result = await getWidgetForDomain('bobsoil.com');

      expect(result).toBeNull();
    });

    it('should normalize domain to lowercase', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(mockPrimaryWidget as any);

      await getWidgetForDomain('MYAMSOIL.COM');

      // Should match as base domain
      expect(prismaMock.turnstileWidget.findFirst).toHaveBeenCalledWith({
        where: { isPrimary: true, isActive: true },
      });
    });

    it('should return null when primary widget not found', async () => {
      prismaMock.turnstileWidget.findFirst.mockResolvedValue(null);

      const result = await getWidgetForDomain('myamsoil.com');

      expect(result).toBeNull();
    });
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- lib/__tests__/turnstile-pool.test.ts`

Expected: FAIL - Cannot find module '../turnstile-pool'

**Step 3: Write the implementation**

Create `lib/turnstile-pool.ts`:

```typescript
import { prisma } from './prisma';
import { decryptSecret } from './turnstile-secrets';
import { logger } from './logger';

/**
 * Widget information for client and server use.
 */
export interface WidgetInfo {
  sitekey: string;
  secretKey: string;
}

/**
 * Base domains that use the primary widget.
 * Subdomains of these are automatically covered.
 */
const BASE_DOMAINS = [
  'myamsoil.com',
  'myamsoil.ca',
  'shopamsoil.com',
  'shopamsoil.ca',
  'localhost',
];

/**
 * Check if a domain is a base domain or subdomain of one.
 */
function isBaseDomain(domain: string): boolean {
  const normalized = domain.toLowerCase().trim();
  return BASE_DOMAINS.some((base) => normalized === base || normalized.endsWith(`.${base}`));
}

/**
 * Find an available widget with capacity for a new hostname.
 * Does not include the primary widget (base domains only).
 *
 * @returns Widget ID
 * @throws Error if no widgets have available capacity
 */
export async function findAvailableWidget(): Promise<string> {
  const widget = await prisma.turnstileWidget.findFirst({
    where: {
      isActive: true,
      hostnameCount: { lt: 15 }, // Use literal for Prisma query
      isPrimary: false,
    },
    orderBy: { hostnameCount: 'desc' }, // Fill existing widgets first
  });

  if (!widget) {
    throw new Error('Turnstile widget pool exhausted. Create a new widget or contact support.');
  }

  return widget.id;
}

/**
 * Get widget info for a specific domain.
 * Returns primary widget for base domains, assigned widget for custom domains.
 *
 * @param domain - The domain to look up
 * @returns Widget info or null if not found
 */
export async function getWidgetForDomain(domain: string): Promise<WidgetInfo | null> {
  const normalizedDomain = domain.toLowerCase().trim();

  // Check if it's a base domain (use primary widget)
  if (isBaseDomain(normalizedDomain)) {
    const primary = await prisma.turnstileWidget.findFirst({
      where: { isPrimary: true, isActive: true },
    });

    if (!primary) {
      logger.warn('Primary Turnstile widget not found');
      return null;
    }

    return {
      sitekey: primary.sitekey,
      secretKey: decryptSecret(primary.secretKeyEncrypted),
    };
  }

  // Look up dealer by custom domain
  const dealer = await prisma.dealer.findFirst({
    where: {
      customDomain: normalizedDomain,
      customDomainStatus: 'active',
    },
    include: { turnstileWidget: true },
  });

  if (!dealer?.turnstileWidget) {
    logger.debug({ domain: normalizedDomain }, 'No Turnstile widget for domain');
    return null;
  }

  return {
    sitekey: dealer.turnstileWidget.sitekey,
    secretKey: decryptSecret(dealer.turnstileWidget.secretKeyEncrypted),
  };
}

/**
 * Get pool utilization status for monitoring.
 */
export async function getPoolStatus(): Promise<{
  widgets: Array<{ name: string; hostnameCount: number; maxHostnames: number }>;
  totalCapacity: number;
  totalUsed: number;
  availableSlots: number;
  utilizationPercent: number;
}> {
  const widgets = await prisma.turnstileWidget.findMany({
    where: { isActive: true },
    select: {
      name: true,
      hostnameCount: true,
      maxHostnames: true,
    },
    orderBy: { name: 'asc' },
  });

  const totalCapacity = widgets.reduce((sum, w) => sum + w.maxHostnames, 0);
  const totalUsed = widgets.reduce((sum, w) => sum + w.hostnameCount, 0);

  return {
    widgets,
    totalCapacity,
    totalUsed,
    availableSlots: totalCapacity - totalUsed,
    utilizationPercent: totalCapacity > 0 ? Math.round((totalUsed / totalCapacity) * 100) : 0,
  };
}
```

**Step 4: Run tests to verify they pass**

Run: `npm test -- lib/__tests__/turnstile-pool.test.ts`

Expected: All 10 tests PASS

**Step 5: Commit**

```bash
git add lib/turnstile-pool.ts lib/__tests__/turnstile-pool.test.ts
git commit -m "feat(turnstile): add widget pool management library"
```

---

### Task 4: Create API Endpoint for Sitekey Lookup

**Files:**

- Create: `app/api/turnstile/sitekey/route.ts`
- Create: `app/api/turnstile/sitekey/__tests__/route.test.ts`

**Step 1: Write the failing test**

Create `app/api/turnstile/sitekey/__tests__/route.test.ts`:

```typescript
import { GET } from '../route';
import { NextRequest } from 'next/server';
import { getWidgetForDomain } from '@/lib/turnstile-pool';

jest.mock('@/lib/turnstile-pool');

const mockGetWidgetForDomain = getWidgetForDomain as jest.MockedFunction<typeof getWidgetForDomain>;

describe('GET /api/turnstile/sitekey', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should return sitekey for valid domain', async () => {
    mockGetWidgetForDomain.mockResolvedValue({
      sitekey: 'test-sitekey-123',
      secretKey: 'secret',
    });

    const request = new NextRequest('http://localhost:3000/api/turnstile/sitekey', {
      headers: { host: 'bobsoil.com' },
    });

    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toEqual({ sitekey: 'test-sitekey-123' });
    expect(mockGetWidgetForDomain).toHaveBeenCalledWith('bobsoil.com');
  });

  it('should strip port from host header', async () => {
    mockGetWidgetForDomain.mockResolvedValue({
      sitekey: 'test-sitekey',
      secretKey: 'secret',
    });

    const request = new NextRequest('http://localhost:3000/api/turnstile/sitekey', {
      headers: { host: 'localhost:3000' },
    });

    await GET(request);

    expect(mockGetWidgetForDomain).toHaveBeenCalledWith('localhost');
  });

  it('should return null sitekey when widget not found', async () => {
    mockGetWidgetForDomain.mockResolvedValue(null);

    const request = new NextRequest('http://localhost:3000/api/turnstile/sitekey', {
      headers: { host: 'unknown.com' },
    });

    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toEqual({ sitekey: null });
  });

  it('should handle missing host header gracefully', async () => {
    mockGetWidgetForDomain.mockResolvedValue(null);

    const request = new NextRequest('http://localhost:3000/api/turnstile/sitekey');

    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toEqual({ sitekey: null });
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- app/api/turnstile/sitekey/__tests__/route.test.ts`

Expected: FAIL - Cannot find module '../route'

**Step 3: Write the implementation**

Create `app/api/turnstile/sitekey/route.ts`:

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { getWidgetForDomain } from '@/lib/turnstile-pool';

export const dynamic = 'force-dynamic';

/**
 * GET /api/turnstile/sitekey
 *
 * Returns the Turnstile sitekey for the current domain.
 * Used by client-side to get the correct widget for custom domains.
 */
export async function GET(request: NextRequest): Promise<NextResponse> {
  const host = request.headers.get('host') || '';
  const domain = host.split(':')[0]; // Remove port if present

  if (!domain) {
    return NextResponse.json({ sitekey: null });
  }

  const widget = await getWidgetForDomain(domain);

  return NextResponse.json({
    sitekey: widget?.sitekey || null,
  });
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- app/api/turnstile/sitekey/__tests__/route.test.ts`

Expected: All 4 tests PASS

**Step 5: Commit**

```bash
git add app/api/turnstile/sitekey/route.ts app/api/turnstile/sitekey/__tests__/route.test.ts
git commit -m "feat(turnstile): add API endpoint for sitekey lookup"
```

---

### Task 5: Update Verification to Use Domain-Specific Secrets

**Files:**

- Modify: `lib/turnstile.ts`
- Modify: `lib/__tests__/turnstile.test.ts`

**Step 1: Write the failing test for domain-aware verification**

Add to `lib/__tests__/turnstile.test.ts`:

```typescript
// Add at top of file with other mocks
jest.mock('../turnstile-pool', () => ({
  getWidgetForDomain: jest.fn(),
}));

import { getWidgetForDomain } from '../turnstile-pool';
const mockGetWidgetForDomain = getWidgetForDomain as jest.MockedFunction<typeof getWidgetForDomain>;

// Add new describe block
describe('verifyTurnstile with domain parameter', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    // Reset fetch mock
    global.fetch = jest.fn();
  });

  it('should use domain-specific secret when domain provided', async () => {
    mockGetWidgetForDomain.mockResolvedValue({
      sitekey: 'domain-sitekey',
      secretKey: 'domain-secret-key',
    });

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => ({ success: true }),
    });

    const result = await verifyTurnstile('test-token', '1.2.3.4', 'bobsoil.com');

    expect(mockGetWidgetForDomain).toHaveBeenCalledWith('bobsoil.com');
    expect(global.fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        body: expect.stringContaining('secret=domain-secret-key'),
      })
    );
    expect(result.success).toBe(true);
  });

  it('should fall back to env secret when domain widget not found', async () => {
    process.env.TURNSTILE_SECRET_KEY = 'env-secret-key';
    mockGetWidgetForDomain.mockResolvedValue(null);

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => ({ success: true }),
    });

    const result = await verifyTurnstile('test-token', '1.2.3.4', 'unknown.com');

    expect(global.fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        body: expect.stringContaining('secret=env-secret-key'),
      })
    );
    expect(result.success).toBe(true);
  });

  it('should use env secret when no domain provided', async () => {
    process.env.TURNSTILE_SECRET_KEY = 'env-secret-key';

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => ({ success: true }),
    });

    const result = await verifyTurnstile('test-token', '1.2.3.4');

    expect(mockGetWidgetForDomain).not.toHaveBeenCalled();
    expect(global.fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        body: expect.stringContaining('secret=env-secret-key'),
      })
    );
    expect(result.success).toBe(true);
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- lib/__tests__/turnstile.test.ts --testNamePattern="domain parameter"`

Expected: FAIL - verifyTurnstile does not accept domain parameter

**Step 3: Update the implementation**

Modify `lib/turnstile.ts` - update the `verifyTurnstile` function signature and implementation:

```typescript
import { getWidgetForDomain } from './turnstile-pool';

// Update the function signature (around line 85)
export async function verifyTurnstile(
  token: string | undefined | null,
  clientIP?: string | null,
  domain?: string  // NEW: Optional domain for widget lookup
): Promise<VerifyTurnstileResult> {
  // Get the correct secret for this domain
  let secretKey = process.env.TURNSTILE_SECRET_KEY;

  if (domain) {
    try {
      const widget = await getWidgetForDomain(domain);
      if (widget) {
        secretKey = widget.secretKey;
      }
    } catch (error) {
      logger.warn({ err: error, domain }, 'Failed to get widget for domain, using default');
    }
  }

  // If Turnstile is not configured, skip verification
  if (!secretKey) {
    logger.debug('Turnstile verification skipped: no secret key configured');
    return { success: true };
  }

  // ... rest of existing implementation unchanged ...
```

**Step 4: Run test to verify it passes**

Run: `npm test -- lib/__tests__/turnstile.test.ts`

Expected: All tests PASS including new domain parameter tests

**Step 5: Commit**

```bash
git add lib/turnstile.ts lib/__tests__/turnstile.test.ts
git commit -m "feat(turnstile): add domain-aware verification with widget lookup"
```

---

### Task 6: Update Domain Activation to Assign Widgets

**Files:**

- Modify: `app/api/dealer/custom-domain/activate/route.ts`
- Create: `lib/turnstile-pool-management.ts` (domain assignment functions)
- Create: `lib/__tests__/turnstile-pool-management.test.ts`

**Step 1: Write failing tests for domain assignment**

Create `lib/__tests__/turnstile-pool-management.test.ts`:

```typescript
import { prismaMock } from '@/lib/__mocks__/prisma';
import { assignDomainToWidget, removeDomainFromWidget } from '../turnstile-pool-management';

// Mock Cloudflare API calls
jest.mock('../turnstile', () => ({
  ...jest.requireActual('../turnstile'),
  addTurnstileHostname: jest.fn().mockResolvedValue({}),
  removeTurnstileHostname: jest.fn().mockResolvedValue({}),
}));

jest.mock('../turnstile-pool', () => ({
  findAvailableWidget: jest.fn(),
}));

import { findAvailableWidget } from '../turnstile-pool';
import { addTurnstileHostname, removeTurnstileHostname } from '../turnstile';

const mockFindAvailableWidget = findAvailableWidget as jest.MockedFunction<
  typeof findAvailableWidget
>;
const mockAddHostname = addTurnstileHostname as jest.MockedFunction<typeof addTurnstileHostname>;
const mockRemoveHostname = removeTurnstileHostname as jest.MockedFunction<
  typeof removeTurnstileHostname
>;

describe('turnstile-pool-management', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('assignDomainToWidget', () => {
    it('should assign dealer to available widget and increment count', async () => {
      mockFindAvailableWidget.mockResolvedValue('widget-1');

      const mockWidget = {
        id: 'widget-1',
        cloudflareWidgetId: 'cf-widget-1',
        hostnameCount: 5,
      };
      prismaMock.turnstileWidget.findUnique.mockResolvedValue(mockWidget as any);
      prismaMock.$transaction.mockResolvedValue([{}, {}]);

      await assignDomainToWidget('dealer-1', 'bobsoil.com');

      expect(mockFindAvailableWidget).toHaveBeenCalled();
      expect(prismaMock.$transaction).toHaveBeenCalled();
      expect(mockAddHostname).toHaveBeenCalledWith('bobsoil.com');
    });

    it('should throw when no widget available', async () => {
      mockFindAvailableWidget.mockRejectedValue(new Error('Turnstile widget pool exhausted'));

      await expect(assignDomainToWidget('dealer-1', 'bobsoil.com')).rejects.toThrow(
        'Turnstile widget pool exhausted'
      );
    });
  });

  describe('removeDomainFromWidget', () => {
    it('should unassign dealer from widget and decrement count', async () => {
      const mockDealer = {
        id: 'dealer-1',
        turnstileWidgetId: 'widget-1',
        turnstileWidget: {
          id: 'widget-1',
          cloudflareWidgetId: 'cf-widget-1',
        },
      };
      prismaMock.dealer.findUnique.mockResolvedValue(mockDealer as any);
      prismaMock.$transaction.mockResolvedValue([{}, {}]);

      await removeDomainFromWidget('dealer-1', 'bobsoil.com');

      expect(prismaMock.$transaction).toHaveBeenCalled();
      expect(mockRemoveHostname).toHaveBeenCalledWith('bobsoil.com');
    });

    it('should do nothing if dealer has no widget assigned', async () => {
      const mockDealer = {
        id: 'dealer-1',
        turnstileWidgetId: null,
        turnstileWidget: null,
      };
      prismaMock.dealer.findUnique.mockResolvedValue(mockDealer as any);

      await removeDomainFromWidget('dealer-1', 'bobsoil.com');

      expect(prismaMock.$transaction).not.toHaveBeenCalled();
      expect(mockRemoveHostname).not.toHaveBeenCalled();
    });
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- lib/__tests__/turnstile-pool-management.test.ts`

Expected: FAIL - Cannot find module '../turnstile-pool-management'

**Step 3: Write the implementation**

Create `lib/turnstile-pool-management.ts`:

```typescript
import { prisma } from './prisma';
import { findAvailableWidget } from './turnstile-pool';
import { addTurnstileHostname, removeTurnstileHostname } from './turnstile';
import { logger } from './logger';

/**
 * Assign a custom domain to a widget from the pool.
 * Called when activating a custom domain.
 *
 * @param dealerId - The dealer ID
 * @param domain - The custom domain being activated
 */
export async function assignDomainToWidget(dealerId: string, domain: string): Promise<void> {
  // Find a widget with available capacity
  const widgetId = await findAvailableWidget();

  // Assign dealer to widget and increment count atomically
  await prisma.$transaction([
    prisma.dealer.update({
      where: { id: dealerId },
      data: { turnstileWidgetId: widgetId },
    }),
    prisma.turnstileWidget.update({
      where: { id: widgetId },
      data: { hostnameCount: { increment: 1 } },
    }),
  ]);

  // Get widget to access Cloudflare ID
  const widget = await prisma.turnstileWidget.findUnique({
    where: { id: widgetId },
  });

  // Add hostname to Cloudflare widget
  // Note: This uses the widget's own API credentials
  if (widget) {
    await addTurnstileHostname(domain);
  }

  logger.info({ dealerId, domain, widgetId }, 'Domain assigned to Turnstile widget');
}

/**
 * Remove a custom domain from its assigned widget.
 * Called when deleting a custom domain.
 *
 * @param dealerId - The dealer ID
 * @param domain - The custom domain being removed
 */
export async function removeDomainFromWidget(dealerId: string, domain: string): Promise<void> {
  // Get dealer with widget info
  const dealer = await prisma.dealer.findUnique({
    where: { id: dealerId },
    select: {
      turnstileWidgetId: true,
      turnstileWidget: true,
    },
  });

  // Nothing to do if no widget assigned
  if (!dealer?.turnstileWidgetId) {
    logger.debug({ dealerId, domain }, 'No Turnstile widget to remove from');
    return;
  }

  // Unassign dealer and decrement count atomically
  await prisma.$transaction([
    prisma.dealer.update({
      where: { id: dealerId },
      data: { turnstileWidgetId: null },
    }),
    prisma.turnstileWidget.update({
      where: { id: dealer.turnstileWidgetId },
      data: { hostnameCount: { decrement: 1 } },
    }),
  ]);

  // Remove hostname from Cloudflare widget
  if (dealer.turnstileWidget) {
    await removeTurnstileHostname(domain);
  }

  logger.info({ dealerId, domain }, 'Domain removed from Turnstile widget');
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- lib/__tests__/turnstile-pool-management.test.ts`

Expected: All 4 tests PASS

**Step 5: Update activate route to use new function**

Modify `app/api/dealer/custom-domain/activate/route.ts`:

Replace the import:

```typescript
import { addTurnstileHostname } from '@/lib/turnstile';
```

With:

```typescript
import { assignDomainToWidget } from '@/lib/turnstile-pool-management';
```

Replace the Turnstile section (around line 152-161):

```typescript
// Add custom domain to Turnstile widget pool for bot protection
try {
  await assignDomainToWidget(dealer.id, dealer.customDomain);
} catch (turnstileError) {
  // Log but don't fail activation - Turnstile is non-critical
  logger.warn(
    { err: turnstileError, customDomain: dealer.customDomain },
    'Failed to assign domain to Turnstile widget (non-fatal)'
  );
}
```

**Step 6: Run existing tests to ensure no regression**

Run: `npm test -- app/api/dealer/custom-domain/activate`

Expected: All tests PASS

**Step 7: Commit**

```bash
git add lib/turnstile-pool-management.ts lib/__tests__/turnstile-pool-management.test.ts app/api/dealer/custom-domain/activate/route.ts
git commit -m "feat(turnstile): assign custom domains to widget pool on activation"
```

---

### Task 7: Update Domain Deletion to Release Widgets

**Files:**

- Modify: `app/api/dealer/custom-domain/route.ts`
- Modify: `app/api/admin/custom-domains/[domain]/route.ts`

**Step 1: Update dealer custom domain DELETE route**

In `app/api/dealer/custom-domain/route.ts`, find the DELETE handler and add import:

```typescript
import { removeDomainFromWidget } from '@/lib/turnstile-pool-management';
```

Replace the Turnstile removal section:

```typescript
// Remove custom domain from Turnstile widget pool
try {
  await removeDomainFromWidget(dealer.id, dealer.customDomain);
} catch (turnstileError) {
  logger.warn(
    { err: turnstileError, customDomain: dealer.customDomain },
    'Failed to remove domain from Turnstile widget (non-fatal)'
  );
}
```

**Step 2: Update admin custom domain DELETE route**

In `app/api/admin/custom-domains/[domain]/route.ts`, add import:

```typescript
import { removeDomainFromWidget } from '@/lib/turnstile-pool-management';
```

Replace the Turnstile removal section:

```typescript
// Remove custom domain from Turnstile widget pool
try {
  await removeDomainFromWidget(dealer.id, domain);
} catch (turnstileError) {
  logger.warn(
    { err: turnstileError, customDomain: domain },
    'Failed to remove domain from Turnstile widget (non-fatal)'
  );
}
```

**Step 3: Run tests to ensure no regression**

Run: `npm test -- custom-domain`

Expected: All custom domain tests PASS

**Step 4: Commit**

```bash
git add app/api/dealer/custom-domain/route.ts app/api/admin/custom-domains/[domain]/route.ts
git commit -m "feat(turnstile): release widget assignment on domain deletion"
```

---

### Task 8: Client-Side Integration via Server Props

**Files:**

- Modify: `components/DealerTemplate.tsx`
- Modify: `app/components/contact-section.tsx`
- Modify: `hooks/useTurnstile.ts`

**Step 1: Update useTurnstile hook to accept undefined siteKey**

The hook already accepts optional siteKey, but verify it handles undefined gracefully:

In `hooks/useTurnstile.ts`, the existing code should handle this:

```typescript
const siteKey = options.siteKey || process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
```

No changes needed if this exists.

**Step 2: Update contact-section to accept sitekey prop**

Modify `app/components/contact-section.tsx`:

Add to interface:

```typescript
interface ContactSectionProps {
  dealer: {
    // ... existing fields
  };
  turnstileSiteKey?: string | null; // NEW
}
```

Update the component:

```typescript
export function ContactSection({ dealer, turnstileSiteKey }: ContactSectionProps) {
  const { containerRef, isVerified, getToken, reset } = useTurnstile({
    siteKey: turnstileSiteKey || undefined, // Use prop if provided
  });
  // ... rest unchanged
}
```

**Step 3: Update DealerTemplate to pass sitekey**

In `components/DealerTemplate.tsx`, the dealer page should fetch and pass the sitekey.

Add to the server component that renders DealerTemplate:

```typescript
import { getWidgetForDomain } from '@/lib/turnstile-pool';
import { headers } from 'next/headers';

// In the page component:
const headersList = headers();
const host = headersList.get('host') || '';
const domain = host.split(':')[0];
const turnstileWidget = await getWidgetForDomain(domain);

// Pass to template:
<DealerTemplate
  dealer={dealer}
  turnstileSiteKey={turnstileWidget?.sitekey || null}
/>
```

**Step 4: Run build to verify no type errors**

Run: `npm run build`

Expected: Build succeeds

**Step 5: Commit**

```bash
git add app/components/contact-section.tsx components/DealerTemplate.tsx
git commit -m "feat(turnstile): pass sitekey via server props for multi-widget support"
```

---

### Task 9: Update Contact API to Use Domain-Aware Verification

**Files:**

- Modify: `app/api/contact/route.ts`

**Step 1: Update verification call to include domain**

In `app/api/contact/route.ts`, find the verifyTurnstile call and update:

```typescript
// Get the domain from request headers
const host = request.headers.get('host') || '';
const domain = host.split(':')[0];

// Update the verification call to include domain
const turnstileResult = await verifyTurnstile(
  payload._turnstile,
  clientIP,
  domain // NEW: Pass domain for widget lookup
);
```

**Step 2: Run tests**

Run: `npm test -- app/api/contact`

Expected: All tests PASS

**Step 3: Commit**

```bash
git add app/api/contact/route.ts
git commit -m "feat(turnstile): use domain-aware verification in contact API"
```

---

### Task 10: Create Widget Setup Script

**Files:**

- Create: `scripts/setup-turnstile-widget.ts`

**Step 1: Write the setup script**

Create `scripts/setup-turnstile-widget.ts`:

```typescript
/**
 * Setup Turnstile Widget
 *
 * Creates a new widget entry in the database from an existing Cloudflare widget.
 * Run this after creating a widget in the Cloudflare dashboard.
 *
 * Usage:
 *   npx tsx scripts/setup-turnstile-widget.ts --name="primary" --sitekey="xxx" --secret="yyy" --cloudflare-id="zzz" --primary
 *   npx tsx scripts/setup-turnstile-widget.ts --name="pool-1" --sitekey="xxx" --secret="yyy" --cloudflare-id="zzz"
 */

import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import { encryptSecret } from '../lib/turnstile-secrets';

// Parse arguments
const args = process.argv.slice(2);
const getArg = (name: string): string | undefined => {
  const arg = args.find((a) => a.startsWith(`--${name}=`));
  return arg?.split('=')[1];
};

const name = getArg('name');
const sitekey = getArg('sitekey');
const secret = getArg('secret');
const cloudflareId = getArg('cloudflare-id');
const isPrimary = args.includes('--primary');
const maxHostnames = parseInt(getArg('max-hostnames') || '15', 10);

async function main(): Promise<void> {
  console.log('='.repeat(60));
  console.log('Turnstile Widget Setup');
  console.log('='.repeat(60));

  // Validate required args
  if (!name || !sitekey || !secret || !cloudflareId) {
    console.error('ERROR: Missing required arguments');
    console.error('');
    console.error('Usage:');
    console.error(
      '  npx tsx scripts/setup-turnstile-widget.ts --name="pool-1" --sitekey="xxx" --secret="yyy" --cloudflare-id="zzz"'
    );
    console.error('');
    console.error('Options:');
    console.error('  --name           Widget name (e.g., "primary", "pool-1")');
    console.error('  --sitekey        Turnstile sitekey from Cloudflare');
    console.error('  --secret         Turnstile secret key from Cloudflare');
    console.error('  --cloudflare-id  Widget ID from Cloudflare dashboard');
    console.error('  --primary        Mark as primary widget (for base domains)');
    console.error('  --max-hostnames  Max hostnames (default: 15)');
    process.exit(1);
  }

  // Validate encryption key
  if (!process.env.TURNSTILE_ENCRYPTION_KEY) {
    console.error('ERROR: TURNSTILE_ENCRYPTION_KEY not set');
    console.error('Generate one with: openssl rand -hex 32');
    process.exit(1);
  }

  // Initialize Prisma
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const adapter = new PrismaPg(pool);
  const prisma = new PrismaClient({ adapter });

  try {
    // Check for existing widget with same cloudflare ID
    const existing = await prisma.turnstileWidget.findUnique({
      where: { cloudflareWidgetId: cloudflareId },
    });

    if (existing) {
      console.error(`ERROR: Widget with Cloudflare ID ${cloudflareId} already exists`);
      console.error(`  Name: ${existing.name}`);
      console.error(`  ID: ${existing.id}`);
      process.exit(1);
    }

    // Check for existing primary if marking as primary
    if (isPrimary) {
      const existingPrimary = await prisma.turnstileWidget.findFirst({
        where: { isPrimary: true },
      });

      if (existingPrimary) {
        console.error('ERROR: A primary widget already exists');
        console.error(`  Name: ${existingPrimary.name}`);
        console.error(`  ID: ${existingPrimary.id}`);
        process.exit(1);
      }
    }

    // Encrypt the secret
    console.log('Encrypting secret key...');
    const encryptedSecret = encryptSecret(secret);

    // Create widget
    console.log('Creating widget record...');
    const widget = await prisma.turnstileWidget.create({
      data: {
        name,
        sitekey,
        secretKeyEncrypted: encryptedSecret,
        cloudflareWidgetId: cloudflareId,
        isPrimary,
        maxHostnames,
        hostnameCount: isPrimary ? 5 : 0, // Primary has base domains
        isActive: true,
      },
    });

    console.log('');
    console.log('SUCCESS! Widget created:');
    console.log(`  ID: ${widget.id}`);
    console.log(`  Name: ${widget.name}`);
    console.log(`  Sitekey: ${widget.sitekey}`);
    console.log(`  Primary: ${widget.isPrimary}`);
    console.log(`  Max Hostnames: ${widget.maxHostnames}`);
    console.log('');

    if (isPrimary) {
      console.log('NOTE: Primary widget should have base domains configured in Cloudflare:');
      console.log('  - myamsoil.com');
      console.log('  - myamsoil.ca');
      console.log('  - shopamsoil.com');
      console.log('  - shopamsoil.ca');
      console.log('  - localhost');
    }

    await prisma.$disconnect();
    await pool.end();
  } catch (error) {
    console.error('Error:', error instanceof Error ? error.message : error);
    await prisma.$disconnect();
    await pool.end();
    process.exit(1);
  }
}

main();
```

**Step 2: Add npm script**

Add to `package.json` scripts:

```json
"setup-turnstile-widget": "tsx scripts/setup-turnstile-widget.ts"
```

**Step 3: Commit**

```bash
git add scripts/setup-turnstile-widget.ts package.json
git commit -m "feat(turnstile): add widget setup script"
```

---

### Task 11: Create Migration Script for Existing Custom Domains

**Files:**

- Create: `scripts/migrate-turnstile-domains.ts`

**Step 1: Write the migration script**

Create `scripts/migrate-turnstile-domains.ts`:

```typescript
/**
 * Migrate Existing Custom Domains to Widget Pool
 *
 * Assigns existing active custom domains to widgets in the pool.
 * Run this after setting up the widget pool.
 *
 * Usage:
 *   npx tsx scripts/migrate-turnstile-domains.ts --dry-run  # Preview
 *   npx tsx scripts/migrate-turnstile-domains.ts            # Execute
 */

import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';

const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');

async function main(): Promise<void> {
  console.log('='.repeat(60));
  console.log('Turnstile Domain Migration');
  console.log('='.repeat(60));

  if (dryRun) {
    console.log('DRY RUN MODE - No changes will be made\n');
  }

  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const adapter = new PrismaPg(pool);
  const prisma = new PrismaClient({ adapter });

  try {
    // Get dealers with active custom domains but no widget assigned
    const dealers = await prisma.dealer.findMany({
      where: {
        customDomain: { not: null },
        customDomainStatus: 'active',
        turnstileWidgetId: null,
      },
      select: {
        id: true,
        subdomain: true,
        customDomain: true,
        businessName: true,
      },
    });

    console.log(`Found ${dealers.length} dealer(s) needing widget assignment:\n`);
    dealers.forEach((d) => {
      console.log(`  - ${d.customDomain} (${d.businessName || d.subdomain})`);
    });

    if (dealers.length === 0) {
      console.log('\nNo migration needed!');
      await prisma.$disconnect();
      await pool.end();
      return;
    }

    // Get available widgets (non-primary, with capacity)
    const widgets = await prisma.turnstileWidget.findMany({
      where: {
        isActive: true,
        isPrimary: false,
      },
      orderBy: { hostnameCount: 'desc' },
    });

    console.log(`\nAvailable widgets: ${widgets.length}`);
    widgets.forEach((w) => {
      console.log(`  - ${w.name}: ${w.hostnameCount}/${w.maxHostnames} hostnames`);
    });

    // Calculate total available slots
    const totalSlots = widgets.reduce((sum, w) => sum + (w.maxHostnames - w.hostnameCount), 0);

    if (totalSlots < dealers.length) {
      console.error(
        `\nERROR: Not enough capacity! Need ${dealers.length} slots, have ${totalSlots}`
      );
      console.error('Create more widgets first.');
      process.exit(1);
    }

    // Assign dealers to widgets
    console.log('\n' + '-'.repeat(60));
    console.log('Assignments:');
    console.log('-'.repeat(60));

    let widgetIndex = 0;
    const assignments: Array<{ dealerId: string; widgetId: string; domain: string }> = [];

    for (const dealer of dealers) {
      // Find widget with capacity
      while (
        widgetIndex < widgets.length &&
        widgets[widgetIndex].hostnameCount >= widgets[widgetIndex].maxHostnames
      ) {
        widgetIndex++;
      }

      if (widgetIndex >= widgets.length) {
        console.error('ERROR: Ran out of widget capacity during assignment');
        process.exit(1);
      }

      const widget = widgets[widgetIndex];
      console.log(`  ${dealer.customDomain} → ${widget.name}`);

      assignments.push({
        dealerId: dealer.id,
        widgetId: widget.id,
        domain: dealer.customDomain!,
      });

      // Update count for next iteration
      widget.hostnameCount++;
    }

    if (dryRun) {
      console.log('\nDRY RUN - Skipping database updates');
    } else {
      console.log('\nApplying assignments...');

      // Group by widget for batch updates
      const byWidget = new Map<string, string[]>();
      for (const a of assignments) {
        if (!byWidget.has(a.widgetId)) {
          byWidget.set(a.widgetId, []);
        }
        byWidget.get(a.widgetId)!.push(a.dealerId);
      }

      // Execute in transaction
      await prisma.$transaction(async (tx) => {
        for (const a of assignments) {
          await tx.dealer.update({
            where: { id: a.dealerId },
            data: { turnstileWidgetId: a.widgetId },
          });
        }

        for (const [widgetId, dealerIds] of byWidget) {
          await tx.turnstileWidget.update({
            where: { id: widgetId },
            data: { hostnameCount: { increment: dealerIds.length } },
          });
        }
      });

      console.log(`\nSUCCESS! Assigned ${assignments.length} domains to widgets.`);
      console.log('\nNOTE: You still need to add these hostnames to Cloudflare widgets manually');
      console.log('or run the sync script: npm run sync-turnstile');
    }

    await prisma.$disconnect();
    await pool.end();
  } catch (error) {
    console.error('Error:', error instanceof Error ? error.message : error);
    await prisma.$disconnect();
    await pool.end();
    process.exit(1);
  }
}

main();
```

**Step 2: Add npm script**

Add to `package.json` scripts:

```json
"migrate-turnstile-domains": "tsx scripts/migrate-turnstile-domains.ts"
```

**Step 3: Commit**

```bash
git add scripts/migrate-turnstile-domains.ts package.json
git commit -m "feat(turnstile): add domain migration script for existing custom domains"
```

---

### Task 12: Update Environment Variables and Documentation

**Files:**

- Modify: `.env.example`
- Modify: `docs/TURNSTILE_SETUP.md`

**Step 1: Update .env.example**

Add new variables:

```env
# Turnstile Multi-Widget Pool
# Generate with: openssl rand -hex 32
TURNSTILE_ENCRYPTION_KEY=

# Optional: Override hostname limit (default: 15)
# TURNSTILE_HOSTNAME_LIMIT=15
```

**Step 2: Update documentation**

Add a new section to `docs/TURNSTILE_SETUP.md`:

````markdown
## Multi-Widget Pool (200+ Custom Domains)

When you have more than 15 custom domains, you need multiple Turnstile widgets.

### Setup

1. **Generate encryption key:**
   ```bash
   openssl rand -hex 32
   ```
````

Add to `.env.local` as `TURNSTILE_ENCRYPTION_KEY`.

2. **Create primary widget in Cloudflare:**
   - Go to Cloudflare Dashboard → Turnstile
   - Create widget with base domains: myamsoil.com, myamsoil.ca, etc.
   - Note the sitekey, secret, and widget ID

3. **Register primary widget:**

   ```bash
   npm run setup-turnstile-widget -- \
     --name="primary" \
     --sitekey="YOUR_SITEKEY" \
     --secret="YOUR_SECRET" \
     --cloudflare-id="WIDGET_ID" \
     --primary
   ```

4. **Create pool widgets (repeat as needed):**

   ```bash
   npm run setup-turnstile-widget -- \
     --name="pool-1" \
     --sitekey="POOL_SITEKEY" \
     --secret="POOL_SECRET" \
     --cloudflare-id="POOL_WIDGET_ID"
   ```

5. **Migrate existing domains:**
   ```bash
   npm run migrate-turnstile-domains -- --dry-run  # Preview
   npm run migrate-turnstile-domains               # Execute
   ```

### Monitoring

Check pool utilization:

```typescript
import { getPoolStatus } from '@/lib/turnstile-pool';
const status = await getPoolStatus();
console.log(
  `Using ${status.totalUsed}/${status.totalCapacity} slots (${status.utilizationPercent}%)`
);
```

### Capacity Planning

- Free tier: 20 widgets × 15 hostnames = 300 custom domains
- Add widgets before reaching 80% utilization
- Monitor via admin dashboard or scheduled job

````

**Step 3: Commit**

```bash
git add .env.example docs/TURNSTILE_SETUP.md
git commit -m "docs(turnstile): add multi-widget pool setup instructions"
````

---

### Task 13: Final Integration Test

**Step 1: Run full test suite**

Run: `npm test`

Expected: All tests PASS

**Step 2: Run build**

Run: `npm run build`

Expected: Build succeeds with no errors

**Step 3: Manual verification checklist**

- [ ] Start dev server: `npm run dev`
- [ ] Create a test widget in Cloudflare dashboard
- [ ] Run setup script to register widget
- [ ] Verify widget appears in database
- [ ] Test contact form on base domain (myamsoil.com subdomain)
- [ ] Test contact form on custom domain (if available)

**Step 4: Final commit**

```bash
git add -A
git commit -m "feat(turnstile): complete multi-widget pool implementation"
```

---

## Summary

| Task | Description               | Files                                              |
| ---- | ------------------------- | -------------------------------------------------- |
| 1    | Database schema           | `prisma/schema.prisma`                             |
| 2    | Encryption utilities      | `lib/turnstile-secrets.ts`                         |
| 3    | Pool management library   | `lib/turnstile-pool.ts`                            |
| 4    | Sitekey API endpoint      | `app/api/turnstile/sitekey/route.ts`               |
| 5    | Domain-aware verification | `lib/turnstile.ts`                                 |
| 6    | Domain activation         | `lib/turnstile-pool-management.ts`, activate route |
| 7    | Domain deletion           | DELETE routes                                      |
| 8    | Client integration        | `contact-section.tsx`, `DealerTemplate.tsx`        |
| 9    | Contact API update        | `app/api/contact/route.ts`                         |
| 10   | Widget setup script       | `scripts/setup-turnstile-widget.ts`                |
| 11   | Migration script          | `scripts/migrate-turnstile-domains.ts`             |
| 12   | Documentation             | `.env.example`, `docs/TURNSTILE_SETUP.md`          |
| 13   | Integration test          | Manual verification                                |

Total: ~13 tasks, ~40 steps, ~1500 lines of code/tests
