# Subdomain Reuse Across Parent Domains - Implementation Plan

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

**Goal:** Allow the same subdomain string to be registered independently on each of the four AMSOIL parent domains (`myamsoil.com`, `myamsoil.ca`, `shopamsoil.com`, `shopamsoil.ca`) by replacing the global uniqueness constraint with a 3-field composite.

**Architecture:** Replace `Dealer.subdomain @unique` (global) + `@@unique([subdomain, domain])` with `@@unique([subdomain, domainPrefix, domain])`. Propagate the full `(subdomain, domainPrefix, domain)` tuple through every dealer lookup path - proxy routing, cached helpers, the dealer page, signup validation, and the admin subdomain editor. On dev/staging hosts (those outside `PRODUCTION_DOMAINS`), preserve existing subdomain-only behavior because test DBs have globally-unique test dealers. Admin editor surfaces "sibling" dealers on other parents as a non-blocking heads-up; public signup silently blocks only exact-match conflicts.

**Tech Stack:** Next.js 16 App Router, React 19, Prisma 7, PostgreSQL, NextAuth, Jest, TypeScript. `lint-staged` runs ESLint+Prettier on commit. All code changes must be made in a git worktree per repo policy.

**Risk posture:** Medium. The routing-half risk was already paid in PR #810. The new risk is the allocation-half + the nondeterminism that emerges from dropping global unique. Rollback is one `git revert` of the merge commit. Migration is reversible (re-add the unique constraint; existing data will satisfy it because every row is still globally unique at the moment of migration).

---

## Pre-flight

### Task 0: Sanity-check the worktree

**Step 1:** Confirm you are in the worktree, not the main checkout.

```bash
pwd
# Expected: ends with /.worktrees/subdomain-reuse
git branch --show-current
# Expected: feature/subdomain-reuse-across-parents
git log --oneline -3
# Expected: starts with the latest dev commit (the canonical-host fix merge)
```

**Step 2:** Confirm the env is set up. If not, set it up.

```bash
grep -E "^(DATABASE_URL|NEXTAUTH_SECRET|PORT)=" .env.local | sed 's/=.*/=<set>/'
# Expected: all 3 set
npx prisma generate 2>&1 | tail -2
# Expected: ✔ Generated Prisma Client
```

**Step 3:** Baseline test run - capture pass count before we start.

```bash
npx jest 2>&1 | tail -6
# Expected: N test suites passed, M tests passed (record these numbers)
```

**Step 4:** Read the recent plan context.

Read `proxy.ts` lines 594–695 (subdomain routing block) and `prisma/schema.prisma` lines 82–165 (Dealer model). These are the two centers of gravity for this change.

---

## Phase A: Schema & Migration

### Task 1: Write the schema change

**Files:**

- Modify: `prisma/schema.prisma` lines 85 and 148–149

**Step 1:** Open `prisma/schema.prisma`. Find this block around line 85:

```prisma
  subdomain            String?          @unique
```

Change to:

```prisma
  subdomain            String?
```

**Step 2:** Find this block around line 148:

```prisma
  @@unique([subdomain, domain])
  @@index([subdomain, domain])
```

Change to:

```prisma
  @@unique([subdomain, domainPrefix, domain])
  @@index([subdomain, domainPrefix, domain])
```

**Step 3:** Save. Do NOT run `prisma generate` yet - the migration runs it for us.

**Step 4:** Commit the schema change alone (easier to revert if migration errors):

```bash
git add prisma/schema.prisma
git commit -m "schema: allow subdomain reuse across parent domain combinations

Replace Dealer.subdomain global @unique + @@unique([subdomain, domain])
with @@unique([subdomain, domainPrefix, domain]) so the same subdomain
string can be independently registered on each of the 4 AMSOIL parents."
```

### Task 2: Generate the migration

**Files:**

- Create: `prisma/migrations/<timestamp>_allow_subdomain_reuse_across_parents/migration.sql`

**Step 1:** Run the migration generator against the dev DB:

```bash
npx prisma migrate dev --name allow_subdomain_reuse_across_parents --create-only 2>&1 | tail -10
```

Expected: a file `prisma/migrations/<timestamp>_allow_subdomain_reuse_across_parents/migration.sql` containing roughly:

```sql
-- DropIndex
DROP INDEX "Dealer_subdomain_key";

-- DropIndex
DROP INDEX "Dealer_subdomain_domain_key";

-- DropIndex
DROP INDEX "Dealer_subdomain_domain_idx";

-- CreateIndex
CREATE UNIQUE INDEX "Dealer_subdomain_domainPrefix_domain_key"
  ON "Dealer"("subdomain", "domainPrefix", "domain");

-- CreateIndex
CREATE INDEX "Dealer_subdomain_domainPrefix_domain_idx"
  ON "Dealer"("subdomain", "domainPrefix", "domain");
```

**Step 2:** Read the generated SQL. If it's missing any of the DROP INDEX statements above, manually edit to include them. The key invariant: every existing index or unique constraint that mentions `subdomain` (other than the new composite) should be dropped.

**Step 3:** Apply the migration:

```bash
npx prisma migrate dev 2>&1 | tail -10
# Expected: "Your database is now in sync with your schema."
```

**Step 4:** Verify the migration landed with raw SQL:

```bash
psql "$DATABASE_URL" -c "\d \"Dealer\"" | grep -iE 'unique|index|subdomain' | head -20
```

Expected: See `Dealer_subdomain_domainPrefix_domain_key UNIQUE` and no more `Dealer_subdomain_key`.

**Step 5:** Commit:

```bash
git add prisma/migrations/
git commit -m "migration: drop global subdomain unique, add composite on (subdomain, domainPrefix, domain)"
```

### Task 3: Run tests - expect TypeScript errors at `findUnique({ subdomain })` sites

**Step 1:** Run the build to surface TypeScript errors:

```bash
npx tsc --noEmit 2>&1 | grep -E "subdomain" | head -30
```

Expected: errors like:

```
app/dealers/[subdomain]/page.tsx(67,...): error TS2322:
  Type '{ subdomain: string; }' is not assignable to type 'DealerWhereUniqueInput'.
  Object literal may only specify known properties, and 'subdomain' does not exist in...
```

This is GOOD. TypeScript is telling us every `findUnique({ where: { subdomain } })` that needs fixing.

**Step 2:** List all affected files - save this list, we'll fix each in later tasks:

```bash
npx tsc --noEmit 2>&1 | grep -E "subdomain" | awk -F':' '{print $1}' | sort -u
```

Expected: at minimum `app/dealers/[subdomain]/page.tsx` and `app/dealers/[subdomain]/[...slug]/page.tsx`. Possibly more.

**Step 3:** Don't commit yet - we'll fix these in Phase D.

---

## Phase B: Tuple-aware helpers

**Goal:** The three cached lookup helpers in `lib/` (`dealer-canonical-host-lookup`, `subdomain-lookup`, `subdomain-redirect-lookup`) all take `subdomain` as their only parameter today. We extend each to optionally take `domainPrefix` + `domain`. When both are provided (production requests), filter by all three and cache under the tuple. When omitted (dev requests), fall back to subdomain-only.

### Task 4: Add host-parts parser to proxy.ts

**Files:**

- Modify: `proxy.ts` (add a new helper function near the existing `extractSubdomain`)
- Test: `__tests__/proxy.test.ts`

**Why this task first:** the next three tasks need a consistent way to extract `(subdomain, domainPrefix, domain)` from a hostname. Do it once in a dedicated helper.

**Step 1: Write the failing test.** Add to `__tests__/proxy.test.ts` at the end of the file (after the canonical-host describe block):

```typescript
describe('extractDealerHostParts', () => {
  // Import via require so Jest picks up the re-exported helper
  const { extractDealerHostParts } = require('../proxy');

  it('returns all three parts for a production myamsoil.com host', () => {
    expect(extractDealerHostParts('bobsoil.myamsoil.com')).toEqual({
      subdomain: 'bobsoil',
      domainPrefix: 'myamsoil',
      domain: 'com',
    });
  });

  it('returns all three parts for a production shopamsoil.ca host', () => {
    expect(extractDealerHostParts('bobsoil.shopamsoil.ca')).toEqual({
      subdomain: 'bobsoil',
      domainPrefix: 'shopamsoil',
      domain: 'ca',
    });
  });

  it('lowercases the output', () => {
    expect(extractDealerHostParts('BobSoil.MyAmsoil.CA')).toEqual({
      subdomain: 'bobsoil',
      domainPrefix: 'myamsoil',
      domain: 'ca',
    });
  });

  it('strips port from hostname', () => {
    expect(extractDealerHostParts('bobsoil.myamsoil.com:3000')).toEqual({
      subdomain: 'bobsoil',
      domainPrefix: 'myamsoil',
      domain: 'com',
    });
  });

  it('returns null for localhost development hosts', () => {
    expect(extractDealerHostParts('test.localhost')).toBeNull();
    expect(extractDealerHostParts('test.localhost:3000')).toBeNull();
  });

  it('returns null for non-production parent domains', () => {
    expect(extractDealerHostParts('bobsoil.acdev3.com')).toBeNull();
    expect(extractDealerHostParts('bobsoil.amsoil.aimclear.com')).toBeNull();
  });

  it('returns null for main app domain with no subdomain', () => {
    expect(extractDealerHostParts('myamsoil.com')).toBeNull();
    expect(extractDealerHostParts('amsoil.aimclear.com')).toBeNull();
  });

  it('returns null for IP addresses', () => {
    expect(extractDealerHostParts('127.0.0.1')).toBeNull();
  });
});
```

**Step 2:** Run the test to see it fail:

```bash
npx jest __tests__/proxy.test.ts -t "extractDealerHostParts" 2>&1 | tail -10
# Expected: "extractDealerHostParts is not a function" or similar failures
```

**Step 3:** Implement the helper. Add to `proxy.ts` immediately after the existing `extractSubdomain` function (around line 410), and export it:

```typescript
/**
 * Extract (subdomain, domainPrefix, domain) from a production hostname.
 * Returns null for dev/staging hosts (where the parent-domain fields have no
 * meaningful value) and for non-dealer hosts.
 *
 * The three returned values correspond to Dealer.subdomain, Dealer.domainPrefix,
 * and Dealer.domain in the schema — so callers can use the tuple as a
 * deterministic DB lookup key under the new @@unique([subdomain, domainPrefix, domain]).
 */
export function extractDealerHostParts(
  hostname: string
): { subdomain: string; domainPrefix: string; domain: string } | null {
  const host = hostname.split(':')[0].toLowerCase();
  const parts = host.split('.');

  // Need exactly three labels: subdomain.prefix.tld
  if (parts.length !== 3) return null;

  const [subdomain, domainPrefix, domain] = parts;

  // Skip main-subdomain reserved names (from extractSubdomain's mainSubdomains list)
  const mainSubdomains = ['www', 'myamsoil', 'amsoil-dlp', 'shopamsoil', 'staging', 'amsoil'];
  if (mainSubdomains.includes(subdomain)) return null;

  // Only recognize the 4 production parent-domain combinations
  const isValidPrefix = domainPrefix === 'myamsoil' || domainPrefix === 'shopamsoil';
  const isValidDomain = domain === 'com' || domain === 'ca';
  if (!isValidPrefix || !isValidDomain) return null;

  return { subdomain, domainPrefix, domain };
}
```

**Step 4:** Run the test again:

```bash
npx jest __tests__/proxy.test.ts -t "extractDealerHostParts" 2>&1 | tail -6
# Expected: 8 passed
```

**Step 5:** Commit:

```bash
git add proxy.ts __tests__/proxy.test.ts
git commit -m "feat(proxy): add extractDealerHostParts helper for tuple-aware lookups"
```

### Task 5: Update `lookupDealerCanonicalHost` to accept optional tuple

**Files:**

- Modify: `lib/dealer-canonical-host-lookup.ts`
- Test: `lib/__tests__/dealer-canonical-host-lookup.test.ts`

**Step 1: Write the failing test.** Add to the describe block in the test file:

```typescript
describe('lookupDealerCanonicalHost (tuple-aware)', () => {
  it('queries by tuple when domainPrefix and domain are provided', async () => {
    const mockFindFirst = jest.fn().mockResolvedValue({
      domain: 'com',
      domainPrefix: 'myamsoil',
      customDomain: null,
      customDomainStatus: 'none',
    });
    (prisma as any).dealer.findFirst = mockFindFirst;

    await lookupDealerCanonicalHost('bobsoil', 'myamsoil', 'com');

    expect(mockFindFirst).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({
          subdomain: 'bobsoil',
          domainPrefix: 'myamsoil',
          domain: 'com',
          status: { in: ['active', 'cancelled_pending'] },
        }),
      })
    );
  });

  it('falls back to subdomain-only query when prefix/domain omitted (dev host)', async () => {
    const mockFindFirst = jest.fn().mockResolvedValue(null);
    (prisma as any).dealer.findFirst = mockFindFirst;

    await lookupDealerCanonicalHost('bobsoil');

    expect(mockFindFirst).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.not.objectContaining({ domainPrefix: expect.anything() }),
      })
    );
  });

  it('caches under tuple key so .com and .ca dealers do not collide', async () => {
    const findFirst = jest
      .fn()
      .mockResolvedValueOnce({
        domain: 'com',
        domainPrefix: 'myamsoil',
        customDomain: null,
        customDomainStatus: 'none',
      })
      .mockResolvedValueOnce({
        domain: 'ca',
        domainPrefix: 'myamsoil',
        customDomain: null,
        customDomainStatus: 'none',
      });
    (prisma as any).dealer.findFirst = findFirst;

    const com = await lookupDealerCanonicalHost('shared', 'myamsoil', 'com');
    const ca = await lookupDealerCanonicalHost('shared', 'myamsoil', 'ca');

    expect(com).toBe('shared.myamsoil.com');
    expect(ca).toBe('shared.myamsoil.ca');
    expect(findFirst).toHaveBeenCalledTimes(2); // both queried, neither served from wrong cache slot
  });
});
```

**Step 2:** Run - expect failure because the function doesn't accept those extra params yet.

```bash
npx jest lib/__tests__/dealer-canonical-host-lookup.test.ts 2>&1 | tail -6
```

**Step 3:** Modify `lib/dealer-canonical-host-lookup.ts`. Change the function signature to:

```typescript
export async function lookupDealerCanonicalHost(
  subdomain: string,
  domainPrefix?: string,
  domain?: string
): Promise<string | null> {
  const normalized = subdomain.toLowerCase();
  // Cache key includes prefix/domain when provided, so dev and prod lookups don't collide
  const cacheKey = domainPrefix && domain ? `${normalized}|${domainPrefix}|${domain}` : normalized;

  const cached = canonicalHostCache.get(cacheKey);
  if (cached && Date.now() < cached.expiresAt) {
    return cached.host;
  }
  canonicalHostCache.delete(cacheKey);

  try {
    const dealer = await prisma.dealer.findFirst({
      where: {
        subdomain: normalized,
        ...(domainPrefix ? { domainPrefix } : {}),
        ...(domain ? { domain } : {}),
        status: { in: ['active', 'cancelled_pending'] },
      },
      select: {
        domain: true,
        domainPrefix: true,
        customDomain: true,
        customDomainStatus: true,
      },
    });

    if (canonicalHostCache.size >= MAX_CACHE_SIZE) evictCacheEntries();

    if (!dealer) {
      canonicalHostCache.set(cacheKey, {
        host: null,
        expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS,
      });
      return null;
    }

    const host =
      dealer.customDomain && dealer.customDomainStatus === 'active'
        ? dealer.customDomain
        : `${normalized}.${dealer.domainPrefix}.${dealer.domain}`;

    canonicalHostCache.set(cacheKey, { host, expiresAt: Date.now() + CACHE_TTL_MS });
    return host;
  } catch (error) {
    logger.error(
      { err: error, subdomain, domainPrefix, domain },
      'Dealer canonical host lookup error'
    );
    return null;
  }
}
```

Also update `invalidateDealerCanonicalHostCache` to accept optional tuple and invalidate all matching entries:

```typescript
export function invalidateDealerCanonicalHostCache(
  subdomain: string,
  domainPrefix?: string,
  domain?: string
): void {
  const normalized = subdomain.toLowerCase();
  if (domainPrefix && domain) {
    canonicalHostCache.delete(`${normalized}|${domainPrefix}|${domain}`);
  } else {
    // Invalidate every key that starts with "subdomain|" or matches "subdomain" exactly
    for (const key of canonicalHostCache.keys()) {
      if (key === normalized || key.startsWith(`${normalized}|`)) {
        canonicalHostCache.delete(key);
      }
    }
  }
}
```

**Step 4:** Run tests:

```bash
npx jest lib/__tests__/dealer-canonical-host-lookup.test.ts 2>&1 | tail -6
# Expected: all pass
```

**Step 5:** Commit:

```bash
git add lib/dealer-canonical-host-lookup.ts lib/__tests__/dealer-canonical-host-lookup.test.ts
git commit -m "feat(lookup): make lookupDealerCanonicalHost tuple-aware"
```

### Task 6: Update `isSubdomainActive` to accept optional tuple

**Files:**

- Modify: `lib/subdomain-lookup.ts`
- Test: `lib/__tests__/subdomain-lookup.test.ts`

**Same pattern as Task 5.** Add optional `domainPrefix` and `domain` params, include them in the `where` clause and cache key.

**Step 1:** Write tests mirroring Task 5's three cases (tuple query, fallback to subdomain-only, separate cache slots for `.com` vs `.ca`).

**Step 2:** Run → fail.

**Step 3:** Implement - same signature change, same cache-key scheme as Task 5. Also update `invalidateSubdomainActiveCache` to match.

**Step 4:** Run → pass.

**Step 5:** Commit:

```bash
git commit -m "feat(lookup): make isSubdomainActive tuple-aware"
```

### Task 7: Update `lookupSubdomainCustomDomain` to accept optional tuple

**Files:**

- Modify: `lib/subdomain-redirect-lookup.ts`
- Test: `lib/__tests__/subdomain-redirect-lookup.test.ts`

**Same pattern as Tasks 5 and 6.** Commit as `feat(lookup): make lookupSubdomainCustomDomain tuple-aware`.

---

## Phase C: Proxy routing

### Task 8: Proxy uses tuple on production, subdomain-only on dev

**Files:**

- Modify: `proxy.ts` lines ~596–693 (subdomain routing block)
- Test: `__tests__/proxy.test.ts`

**Step 1: Write a new failing test.** Add to the `Proxy - Canonical Host Enforcement` describe block:

```typescript
it('should query lookupDealerCanonicalHost with full tuple on production', async () => {
  mockLookupDealerCanonicalHost.mockResolvedValue('bobsoil.myamsoil.com');

  const request = new NextRequest('http://bobsoil.myamsoil.com/', {
    headers: { host: 'bobsoil.myamsoil.com' },
  });
  await proxy(request);

  expect(mockLookupDealerCanonicalHost).toHaveBeenCalledWith('bobsoil', 'myamsoil', 'com');
});

it('should call lookups with subdomain only on dev hosts', async () => {
  const { isSubdomainActive } = jest.requireMock('../lib/subdomain-lookup');

  const request = new NextRequest('http://bobsoil.localhost:3000/', {
    headers: { host: 'bobsoil.localhost:3000' },
  });
  await proxy(request);

  // isSubdomainActive called with single argument (subdomain only)
  expect(isSubdomainActive).toHaveBeenCalledWith('bobsoil');
});
```

**Step 2:** Run - expect failures. Check the mock call args in the failure output.

**Step 3:** Modify the subdomain routing block in `proxy.ts` (the existing block around line 596+). Replace the body of `if (subdomain)` with logic that computes the tuple if available:

```typescript
const subdomain = extractSubdomain(hostname);
if (subdomain) {
  // Compute optional prefix/domain from hostname. On production hosts this
  // returns the tuple; on dev hosts it returns null and the lookups fall back
  // to subdomain-only.
  const parts = extractDealerHostParts(hostname);
  const domainPrefix = parts?.domainPrefix;
  const domain = parts?.domain;

  // ... bot probe checks (unchanged) ...

  if (!isStaticAsset) {
    // ... method guard (unchanged) ...

    // Canonical enforcement: only fires on production (parts !== null)
    const hostWithoutPort = hostname.split(':')[0].toLowerCase();
    if (parts && isProductionDomain(hostWithoutPort)) {
      const canonicalHost = (
        await lookupDealerCanonicalHost(subdomain, domainPrefix, domain)
      )?.toLowerCase();
      if (canonicalHost && canonicalHost !== hostWithoutPort) {
        logAggregator.record(
          'proxy.canonical_mismatch_redirect',
          `${hostWithoutPort}->${canonicalHost}`
        );
        return NextResponse.redirect(
          `https://${canonicalHost}${pathname}${request.nextUrl.search}`,
          301
        );
      }
    }

    const customDomain = await lookupSubdomainCustomDomain(subdomain, domainPrefix, domain);
    if (customDomain) {
      return NextResponse.redirect(
        `https://${customDomain}${pathname}${request.nextUrl.search}`,
        301
      );
    }

    const isActive = await isSubdomainActive(subdomain, domainPrefix, domain);
    if (!isActive) {
      return new NextResponse('Not Found', {
        status: 404,
        headers: {
          'Content-Type': 'text/plain',
          'Cache-Control': 'no-store, no-cache, must-revalidate',
        },
      });
    }

    // ... rest unchanged (slug path handling, rewrite) ...
  }
}
```

**Step 4:** Run the new tests and the pre-existing ones:

```bash
npx jest __tests__/proxy.test.ts 2>&1 | tail -6
# Expected: all pass
```

**Step 5:** Commit:

```bash
git commit -m "feat(proxy): pass (subdomain, domainPrefix, domain) tuple to lookups on production"
```

### Task 9: Proxy sets tuple headers alongside x-dealer-subdomain

**Files:**

- Modify: `proxy.ts` - the three places that set `x-dealer-subdomain`
- Modify: `proxy.ts#nextWithoutDealerHeader` - strip new headers too (prevent spoofing)
- Test: `__tests__/proxy.test.ts`

**Why:** the dealer page and API handlers need to disambiguate when two dealers share a subdomain. The proxy knows the full tuple; it communicates it downstream via request headers.

**Step 1: Write failing tests.** Add to the existing `Proxy - Dealer Context Header for API Routes` describe:

```typescript
it('should set x-dealer-domain-prefix and x-dealer-domain headers on production subdomain requests', async () => {
  const request = new NextRequest('http://bobsoil.myamsoil.com/api/stats/events', {
    method: 'POST',
    headers: { host: 'bobsoil.myamsoil.com', origin: 'http://bobsoil.myamsoil.com' },
  });
  const response = await proxy(request);
  const setHeaders = Array.from(response.headers.entries());
  expect(setHeaders.find(([k]) => k === 'x-middleware-request-x-dealer-domain-prefix')?.[1]).toBe(
    'myamsoil'
  );
  expect(setHeaders.find(([k]) => k === 'x-middleware-request-x-dealer-domain')?.[1]).toBe('com');
});

it('should strip x-dealer-domain-prefix on main app API requests to prevent spoofing', async () => {
  const request = new NextRequest('http://amsoil.aimclear.com/api/admin/dealers', {
    headers: {
      host: 'amsoil.aimclear.com',
      'x-dealer-domain-prefix': 'myamsoil', // attacker attempt
      'x-dealer-domain': 'com', // attacker attempt
    },
  });
  await proxy(request);
  // Assertion: the request forwarded to downstream handlers has no such header.
  // Check via the delete behavior documented in nextWithoutDealerHeader.
});
```

**Step 2:** Run - expect failures.

**Step 3:** Implement:

In `proxy.ts`, every call to `requestHeaders.set('x-dealer-subdomain', subdomain)` should also set:

```typescript
if (domainPrefix) requestHeaders.set('x-dealer-domain-prefix', domainPrefix);
if (domain) requestHeaders.set('x-dealer-domain', domain);
```

In `nextWithoutDealerHeader` (around line 417), extend to strip all three:

```typescript
function nextWithoutDealerHeader(request: NextRequest): NextResponse {
  const DEALER_HEADERS = ['x-dealer-subdomain', 'x-dealer-domain-prefix', 'x-dealer-domain'];
  if (DEALER_HEADERS.some((h) => request.headers.has(h))) {
    const headers = new Headers(request.headers);
    DEALER_HEADERS.forEach((h) => headers.delete(h));
    return NextResponse.next({ request: { headers } });
  }
  return NextResponse.next();
}
```

In the `rewrite` path at line 693, also pass these via headers so the page can read them:

```typescript
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-dealer-subdomain', subdomain);
if (domainPrefix) requestHeaders.set('x-dealer-domain-prefix', domainPrefix);
if (domain) requestHeaders.set('x-dealer-domain', domain);
return NextResponse.rewrite(new URL(`/dealers/${subdomain}${pathname}`, request.url), {
  request: { headers: requestHeaders },
});
```

**Step 4:** Run all proxy tests:

```bash
npx jest __tests__/proxy.test.ts 2>&1 | tail -6
```

**Step 5:** Commit:

```bash
git commit -m "feat(proxy): set x-dealer-domain-prefix and x-dealer-domain headers; strip on non-dealer paths"
```

---

## Phase D: Dealer page

### Task 10: Dealer homepage queries by tuple from headers

**Files:**

- Modify: `app/dealers/[subdomain]/page.tsx` around line 67 (`findUnique({ where: { subdomain } })`)

**Step 1:** Read the full `getCachedDealer` function in `app/dealers/[subdomain]/page.tsx`. It's currently called with just `subdomain`. We'll make it accept the full tuple and read it from the request headers.

**Step 2:** Near the top of the file, add a helper:

```typescript
import { headers } from 'next/headers';

function getDealerTupleFromHeaders() {
  const h = headers();
  return {
    subdomain: h.get('x-dealer-subdomain') ?? undefined,
    domainPrefix: h.get('x-dealer-domain-prefix') ?? undefined,
    domain: h.get('x-dealer-domain') ?? undefined,
  };
}
```

**Step 3:** Change `getCachedDealer` to accept the tuple:

```typescript
function getCachedDealer(subdomain: string, domainPrefix?: string, domain?: string) {
  return unstable_cache(
    async () => {
      return prisma.dealer.findFirst({
        where: {
          subdomain,
          ...(domainPrefix ? { domainPrefix } : {}),
          ...(domain ? { domain } : {}),
        },
        select: {
          /* unchanged */
        },
      });
    },
    [`dealer-${subdomain}-${domainPrefix ?? 'any'}-${domain ?? 'any'}`],
    { tags: [`dealer-${subdomain}`] }
  );
}
```

**Step 4:** At the call site inside the component, change from the route-param subdomain to the header-provided tuple:

```typescript
// OLD: const dealer = await getCachedDealer(params.subdomain)();
const tuple = getDealerTupleFromHeaders();
const dealerFn = getCachedDealer(
  tuple.subdomain ?? params.subdomain,
  tuple.domainPrefix,
  tuple.domain
);
const dealer = await dealerFn();
```

**Step 5:** Run tsc to confirm this file no longer errors:

```bash
npx tsc --noEmit 2>&1 | grep "app/dealers/\[subdomain\]/page.tsx" | head -5
# Expected: no output
```

**Step 6:** Run the dealer page tests if they exist:

```bash
npx jest app/dealers 2>&1 | tail -6
```

**Step 7:** Commit:

```bash
git commit -m "feat(dealers): query dealer by tuple from proxy-set headers"
```

### Task 11: Dealer slug page (same change as Task 10)

**Files:**

- Modify: `app/dealers/[subdomain]/[...slug]/page.tsx` around line 45

**Apply the exact same transformation as Task 10.** Commit as `feat(dealers): slug page queries dealer by tuple`.

### Task 12: OG image service accepts tuple

**Files:**

- Modify: `lib/og-image-service.ts` (function `generateOgImage` and the `prisma.dealer.findFirst` call at line 38)

**Step 1:** Change the signature of `generateOgImage` to accept `domainPrefix?: string, domain?: string` (add to the existing `options` object).

**Step 2:** Update the `findFirst` call to include prefix/domain when provided:

```typescript
const dealer = await prisma.dealer.findFirst({
  where: {
    subdomain,
    ...(domainPrefix ? { domainPrefix } : {}),
    ...(domain ? { domain } : {}),
  },
  select: {
    /* unchanged */
  },
});
```

**Step 3:** Find all callers of `generateOgImage`. For each, if the caller has access to the tuple (e.g., through the OG route handler, which receives the request), pass it. If not, document in a comment that the caller falls back to subdomain-only (safe on dev, acceptable on prod if the OG route is only hit via proxy which sets headers).

**Step 4:** Run tsc and relevant tests.

**Step 5:** Commit:

```bash
git commit -m "feat(og): accept optional (domainPrefix, domain) tuple for deterministic dealer lookup"
```

---

## Phase E: Validation & signup API

### Task 13: `validateSubdomain` accepts tuple, returns siblings

**Files:**

- Modify: `lib/subdomain-validation.ts`
- Test: `lib/__tests__/subdomain-validation.test.ts` (create if missing)

**Step 1: Write failing tests.** Create or extend the test file:

```typescript
import { validateSubdomain } from '../subdomain-validation';
jest.mock('../prisma', () => ({
  prisma: { dealer: { findFirst: jest.fn(), findMany: jest.fn() } },
}));
import { prisma } from '../prisma';

describe('validateSubdomain (tuple-aware)', () => {
  beforeEach(() => jest.clearAllMocks());

  it('returns available when no dealer exists at the target tuple', async () => {
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue(null);
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([]);

    const result = await validateSubdomain('newsub', 'myamsoil', 'com');

    expect(result).toEqual({ available: true, siblings: [] });
  });

  it('returns unavailable when dealer exists at the exact target tuple', async () => {
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue({ id: 'x' });
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([]);

    const result = await validateSubdomain('taken', 'myamsoil', 'com');

    expect(result.available).toBe(false);
    expect(result.reason).toBe('Already registered');
  });

  it('returns available with siblings when dealer exists only on a different parent', async () => {
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue(null);
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      {
        subdomain: 'taken',
        domainPrefix: 'myamsoil',
        domain: 'ca',
        businessName: 'OKV Engine Oils',
      },
    ]);

    const result = await validateSubdomain('taken', 'myamsoil', 'com');

    expect(result.available).toBe(true);
    expect(result.siblings).toHaveLength(1);
    expect(result.siblings![0]).toMatchObject({
      subdomain: 'taken',
      domainPrefix: 'myamsoil',
      domain: 'ca',
    });
  });

  it('excludes the target tuple from sibling results', async () => {
    (prisma.dealer.findFirst as jest.Mock).mockResolvedValue(null);
    // findMany mock returns dealer on the exact requested tuple + one sibling
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      { subdomain: 'taken', domainPrefix: 'myamsoil', domain: 'com' }, // same tuple (shouldn't appear)
      { subdomain: 'taken', domainPrefix: 'myamsoil', domain: 'ca' }, // sibling
    ]);

    const result = await validateSubdomain('taken', 'myamsoil', 'com');

    expect(result.siblings).toHaveLength(1);
    expect(result.siblings![0].domain).toBe('ca');
  });
});
```

**Step 2:** Run → fail.

**Step 3:** Replace the current `validateSubdomain` function body in `lib/subdomain-validation.ts`:

```typescript
export async function validateSubdomain(
  subdomain: string,
  domainPrefix: string,
  domain: string
): Promise<{
  available: boolean;
  reason?: string;
  siblings?: Array<{
    subdomain: string;
    domainPrefix: string;
    domain: string;
    businessName: string | null;
  }>;
}> {
  // Format & reserved-name checks (unchanged)
  const prohibitedTerm = containsProhibitedTerm(subdomain);
  if (prohibitedTerm) return { available: false, reason: `Cannot contain "${prohibitedTerm}"` };
  if (!isValidSubdomainFormat(subdomain)) return { available: false, reason: 'Invalid format' };
  if (RESERVED_SUBDOMAINS.includes(subdomain))
    return { available: false, reason: 'Reserved subdomain' };

  // Exact-tuple conflict blocks allocation
  const exactMatch = await prisma.dealer.findFirst({
    where: { subdomain, domainPrefix, domain },
    select: { id: true },
  });
  if (exactMatch) return { available: false, reason: 'Already registered' };

  // Siblings — other dealers with same subdomain on other parent combos
  const allWithSubdomain = await prisma.dealer.findMany({
    where: { subdomain },
    select: { subdomain: true, domainPrefix: true, domain: true, businessName: true },
  });
  const siblings = allWithSubdomain.filter(
    (d) => !(d.domainPrefix === domainPrefix && d.domain === domain)
  );

  return { available: true, siblings };
}
```

**Step 4:** Run tests → pass.

**Step 5:** Commit:

```bash
git commit -m "feat(validation): validateSubdomain takes tuple, returns siblings on available"
```

### Task 14: Update `/api/check-subdomain` route to accept tuple

**Files:**

- Modify: `app/api/check-subdomain/route.ts`
- Test: `app/api/check-subdomain/__tests__/route.test.ts`

**Step 1:** Read the current route. It accepts `?name=` query param; extend to also accept `?domainPrefix=` and `?domain=`, with sensible defaults (`myamsoil`, `com`) to keep backward compatibility for any untouched callers.

**Step 2:** Write a test that hits the route with all three params and asserts the response includes `siblings`.

**Step 3:** Update the route handler:

```typescript
const { searchParams } = new URL(request.url);
const rawSubdomain = searchParams.get('name');
const domainPrefix = searchParams.get('domainPrefix') || 'myamsoil';
const domain = searchParams.get('domain') || 'com';

// Validate domainPrefix and domain against allowlist
if (!['myamsoil', 'shopamsoil'].includes(domainPrefix)) {
  return NextResponse.json({ error: 'Invalid domainPrefix' }, { status: 400 });
}
if (!['com', 'ca'].includes(domain)) {
  return NextResponse.json({ error: 'Invalid domain' }, { status: 400 });
}

// ... existing subdomain normalization ...

const validationResult = await validateSubdomain(subdomain, domainPrefix, domain);

// Return siblings only to admins (we'll wire this up in Task 15/16 for the public flow; here
// unconditionally omit siblings from the response, since this is the public endpoint)
const { siblings, ...publicResult } = validationResult;
return NextResponse.json({ subdomain, ...publicResult });
```

**Step 4:** Run the tests → pass.

**Step 5:** Commit:

```bash
git commit -m "feat(api): /api/check-subdomain accepts (domainPrefix, domain), omits siblings for public"
```

### Task 15: Public signup form passes tuple

**Files:**

- Find with: `grep -rn "check-subdomain" app/ components/ --include="*.tsx" | grep -v test`
- Modify the relevant fetch call(s) to include `domainPrefix` and `domain` query params based on the user's registration form selections.

**Step 1:** Identify the signup-form component calling `/api/check-subdomain`. Likely `app/registration/.../OnboardingForm.tsx` or a hook next to it.

**Step 2:** Find where `domain` (com/ca) is selected in the form. Add `domainPrefix` default of `'myamsoil'` (unless the form also exposes a prefix selector - unlikely for public signup).

**Step 3:** Update the fetch URL to include both params:

```typescript
const url = `/api/check-subdomain?name=${encodeURIComponent(subdomain)}&domainPrefix=${domainPrefix}&domain=${domain}`;
```

**Step 4:** Run relevant component tests (`OnboardingForm.test.tsx` was seen earlier) and update snapshots if needed.

**Step 5:** Commit:

```bash
git commit -m "feat(signup): public signup form sends (domainPrefix, domain) to check-subdomain"
```

---

## Phase F: Admin subdomain editor

### Task 16: Admin subdomain route uses composite check + returns siblings

**Files:**

- Modify: `app/api/admin/dealers/[id]/subdomain/route.ts` around lines 169–178
- Test: `app/api/admin/dealers/[id]/subdomain/__tests__/route.test.ts`

**Step 1:** Read the current route. Today it queries `.com` and `.ca` separately and rejects if either has a conflict. Change to: query the exact `(subdomain, domainPrefix, domain)` tuple; reject only on exact match.

**Step 2:** Write failing tests:

```typescript
it('allows taking a subdomain that is registered on a different parent domain', async () => {
  // sibling exists on myamsoil.ca; admin wants to put this dealer on myamsoil.com
  // Expected: 200 response, siblings array with 1 entry, no error.
});

it('rejects taking a subdomain already on the exact target parent', async () => {
  // sibling exists on myamsoil.com; admin trying to move THIS dealer to myamsoil.com (not its own)
  // Expected: 409 response with existing error message.
});

it('returns siblings array with other-parent dealers for admin UI warning', async () => {
  // ...
});
```

**Step 3:** Replace lines 169–191 (the `existingOnCom` / `existingOnCa` block) with:

```typescript
const newDomainPrefix = body.domainPrefix ?? dealer.domainPrefix;
const newDomain = body.domain ?? dealer.domain;

const exactMatch = await prisma.dealer.findFirst({
  where: {
    subdomain: newSubdomain,
    domainPrefix: newDomainPrefix,
    domain: newDomain,
    id: { not: dealerId },
  },
  select: { id: true, businessName: true },
});

if (exactMatch) {
  return NextResponse.json(
    { error: 'Subdomain is already in use by another dealer on this parent domain' },
    { status: 409 }
  );
}

// Siblings: same subdomain on other parents (not blocking, just informational)
const siblings = await prisma.dealer.findMany({
  where: {
    subdomain: newSubdomain,
    NOT: { AND: [{ domainPrefix: newDomainPrefix }, { domain: newDomain }] },
    id: { not: dealerId },
  },
  select: { subdomain: true, domainPrefix: true, domain: true, businessName: true },
});
```

Include `siblings` in the success response:

```typescript
return NextResponse.json({
  success: true,
  subdomain: updatedDealer.subdomain,
  siblings, // NEW
});
```

**Step 4:** Run admin route tests → all pass.

**Step 5:** Commit:

```bash
git commit -m "feat(admin): subdomain route allows sibling subdomains, returns siblings for UI warning"
```

### Task 17: EditDNSModal renders sibling warning

**Files:**

- Modify: `components/admin/EditDNSModal.tsx`
- Find the form submit handler and the place where it renders the validation result

**Step 1:** Identify where the modal processes the response from the admin subdomain API. It currently reads `success` / `error`; extend to read `siblings`.

**Step 2:** When `siblings.length > 0`, render a warning block above the "Confirm" button. Use existing CSS module classes or add a new `.siblingsWarning` class to `EditDNSModal.module.css`:

```tsx
{
  siblings && siblings.length > 0 && (
    <div className={styles.siblingsWarning}>
      <strong>Note:</strong> this subdomain already exists on{' '}
      {siblings
        .map((s) => `${s.subdomain}.${s.domainPrefix}.${s.domain} (${s.businessName ?? 'unnamed'})`)
        .join(', ')}
      . Visitors on those URLs will continue to reach the other dealer.
    </div>
  );
}
```

**Step 3:** Add styling to `EditDNSModal.module.css`:

```css
.siblingsWarning {
  background: var(--warning-bg, #fff8e1);
  border-left: 3px solid var(--warning-border, #f9a825);
  padding: 12px;
  margin: 12px 0;
  font-size: 14px;
}
```

**Step 4:** Manual smoke check - start the dev server, open the admin panel, edit a subdomain for a test dealer where a sibling exists, and verify the warning renders.

```bash
npm run dev &  # port 3000
# visit http://localhost:3000/admin and edit a dealer subdomain
```

**Step 5:** Commit:

```bash
git commit -m "feat(admin-ui): EditDNSModal surfaces sibling dealers as a non-blocking warning"
```

---

## Phase G: Audit and fix remaining `findFirst({ subdomain })` sites

### Task 18: Audit `lib/tier-handlers.ts`, `lib/og-image-service.ts`, `lib/csrf.ts`

**Step 1:** Grep for any remaining subdomain-only `findFirst` / `findUnique`:

```bash
grep -rn "findUnique.*subdomain\|findFirst.*subdomain" lib/ app/ --include="*.ts" --include="*.tsx" | grep -v test | grep -v "subdomain:.*domainPrefix"
```

**Step 2:** For each match, decide whether the caller has the tuple available. Cases:

- **`lib/tier-handlers.ts`** - invoked from Stripe webhook. The webhook event contains `stripeCustomerId`. Change these to `findUnique({ where: { stripeCustomerId } })` instead of subdomain-based lookup. This is both more correct and avoids the tuple-ambiguity question.

- **`lib/csrf.ts`** - queries by `customDomain`, not `subdomain`. Unaffected. Leave alone.

- **`lib/og-image-service.ts`** - already handled in Task 12.

**Step 3:** Run full test suite to catch anything the static audit missed:

```bash
npx jest 2>&1 | tail -10
```

**Step 4:** Commit (may be empty if no changes needed):

```bash
git commit --allow-empty -m "chore: audit findFirst({subdomain}) sites; tier-handlers now keys on stripeCustomerId"
```

---

## Phase H: End-to-end verification

### Task 19: Seed duplicate test dealers

**Files:**

- Create: `scripts/seed-duplicate-test-dealers.ts` (temporary; will be deleted before merge)

**Step 1:** Create a script that adds a second dealer with subdomain `test-starter` on `myamsoil.ca` (while the existing one stays on `myamsoil.com`):

```typescript
// scripts/seed-duplicate-test-dealers.ts
import { prisma } from '../lib/prisma';

async function main() {
  const existing = await prisma.dealer.findFirst({
    where: { subdomain: 'test-starter', domainPrefix: 'myamsoil', domain: 'ca' },
  });
  if (existing) {
    console.log('Already exists');
    return;
  }

  const user = await prisma.user.create({
    data: { email: 'test-starter-ca@claude.dev', role: 'dealer', emailVerified: new Date() },
  });
  await prisma.dealer.create({
    data: {
      userId: user.id,
      subdomain: 'test-starter',
      domain: 'ca',
      domainPrefix: 'myamsoil',
      subscriptionTier: 'starter',
      stripeCustomerId: 'cus_test_starter_ca',
      stripeSubscriptionId: 'sub_test_starter_ca',
      businessName: 'Test Starter (CA sibling)',
      phone: '555-555-0199',
      address: '123 Test St',
      city: 'Test',
      state: 'ON',
      zip: 'X0X 0X0',
      country: 'CA',
      status: 'active',
    },
  });
  console.log('Created test-starter.myamsoil.ca sibling dealer');
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
```

**Step 2:** Run it:

```bash
npx tsx scripts/seed-duplicate-test-dealers.ts
# Expected: Created test-starter.myamsoil.ca sibling dealer
```

**Step 3:** Verify in the DB:

```bash
psql "$DATABASE_URL" -c 'SELECT subdomain, "domainPrefix", domain, "businessName" FROM "Dealer" WHERE subdomain = $$test-starter$$;'
# Expected: 2 rows
```

### Task 20: Local /etc/hosts + dev server verification

**Step 1:** Add temp entries to `/etc/hosts`:

```bash
sudo sh -c 'cat >> /etc/hosts <<EOF

# TEMP: subdomain-reuse verification — remove after testing
127.0.0.1 test-starter.myamsoil.com test-starter.myamsoil.ca
EOF'
```

**Step 2:** Start dev server (PORT should come from worktree's .env.local):

```bash
npm run dev &
sleep 15
```

**Step 3:** Hit both URLs and confirm they serve DIFFERENT dealer pages:

```bash
curl -sI "http://test-starter.myamsoil.com:3000/" | grep -iE 'HTTP|x-middleware-rewrite'
curl -sI "http://test-starter.myamsoil.ca:3000/" | grep -iE 'HTTP|x-middleware-rewrite'
# Expected: both 200 with different page contents (check via `curl | grep businessName`)
```

**Step 4:** Hit the canonical admin form endpoint and confirm siblings come back:

```bash
# You'll need an admin session cookie — skip if tricky, rely on the unit test coverage.
```

**Step 5:** Stop the server and revert /etc/hosts:

```bash
# Stop server (find PID via `lsof -i :3000`, kill it, or pkill -f "next dev")
sudo sed -i '/subdomain-reuse verification/,+2 d' /etc/hosts
```

**Step 6:** Delete the seed script; it's done its job:

```bash
rm scripts/seed-duplicate-test-dealers.ts
```

**Step 7:** Remove the duplicate dealer from the DB:

```bash
psql "$DATABASE_URL" -c "DELETE FROM \"User\" WHERE email = 'test-starter-ca@claude.dev';"
# Dealer is cascade-deleted via onDelete: Cascade.
```

**Step 8:** Commit the cleanup (if any files remain - likely none):

```bash
git status  # should be clean
```

### Task 21: Full test + build + open PR

**Step 1:** Full test suite:

```bash
npx jest 2>&1 | tail -10
# Expected: all tests pass, count >= baseline from Task 0 Step 3
```

**Step 2:** Build-tsc:

```bash
npx tsc --noEmit 2>&1 | grep -v "mcp/dealer-lookup" | tail -10
# Expected: no errors outside the pre-existing mcp/dealer-lookup ones
```

**Step 3:** Push:

```bash
git push -u origin feature/subdomain-reuse-across-parents
```

**Step 4:** Open PR to `dev`:

```bash
gh pr create --base dev --title "Allow subdomain reuse across AMSOIL parent domains" --body "$(cat <<'EOF'
## Summary
- Drop global `subdomain @unique`; add `@@unique([subdomain, domainPrefix, domain])` so the same subdomain can exist independently on each of the 4 parent domains
- Proxy and all dealer lookups now use the full `(subdomain, domainPrefix, domain)` tuple on production; dev/staging falls back to subdomain-only
- Admin subdomain editor warns about sibling dealers on other parents (non-blocking); public signup silently blocks only exact-match conflicts
- Follow-up to #810 (canonical-host enforcement)

## Test plan
- [x] 155+ unit tests passing (schema, helpers, proxy, validation, admin route)
- [x] Local /etc/hosts + dev-server verification: two dealers at `test-starter.myamsoil.com` and `test-starter.myamsoil.ca` each served their own page
- [x] Admin modal sibling warning rendered for a subdomain with an existing sibling
- [ ] Post-merge: verify on staging that existing dealers are unaffected (routing still serves the canonical)
- [ ] Post-merge: attempt to register a duplicate-parent subdomain via admin — should succeed with sibling warning

## Rollback
Revert the merge commit. Migration is reversible (the re-added global unique constraint will be satisfied by existing data as long as no one has exploited the relaxation yet).
EOF
)"
```

**Step 5:** Use `ci-watch` skill to monitor CI. Address any review bot findings with follow-up commits as in the #810 playbook.

---

## Done Criteria

- [ ] `prisma/schema.prisma` has `@@unique([subdomain, domainPrefix, domain])`, no more global `subdomain @unique`
- [ ] All three `lib/` lookup helpers accept the optional tuple
- [ ] `proxy.ts` passes the tuple to all three helpers on production hosts
- [ ] Dealer pages (`/dealers/[subdomain]/*`) query by the tuple via headers
- [ ] `validateSubdomain` returns siblings; admin API returns them to the modal
- [ ] `EditDNSModal` renders the sibling warning when applicable
- [ ] Full test suite green; `npx tsc --noEmit` has no new errors
- [ ] End-to-end local verification with two duplicate-subdomain dealers
- [ ] PR open to `dev`, ci-watch run complete, review bot findings addressed

## Out of Scope (explicit non-goals)

- UI changes to the public signup form beyond the query-param plumbing - we're not adding a domainPrefix selector to public signup unless business rules change
- Migrating existing dealer records - there are none to migrate; every current dealer already has a unique `(subdomain, domainPrefix, domain)` tuple because the old global unique guaranteed it
- `extractSubdomain()` lowercasing - still deferred to a separate PR as noted in PR #810's follow-ups list
- Feature flag - intentionally not adding one; rollback via revert is sufficient given the small blast radius
