---
phase: 1
plan: 1
type: execute
wave: 1
depends_on: []
files_modified:
  - package.json
  - next.config.js
  - middleware.js
  - app/api/auth/local/route.js
  - app/login/LocalPasswordForm.js
  - app/api/guard/route.js
  - app/api/keys/route.js
  - app/api/team/route.js
  - app/api/usage/route.js
  - scripts/migrate-api-keys-compat.mjs
  - scripts/migrate-multi-tenant.mjs
  - app/lib/setup/runtime-prerequisites.mjs
  - docker-compose.yml
  - __tests__/unit/lucide-exports.test.js
  - __tests__/unit/security-headers.test.js
autonomous: true
requirements: [FIX-01, FIX-02, FIX-03, FIX-04]
must_haves:
  truths:
    - "Fresh `npm ci && npm run build` on Node 20 exits 0 with no lucide-react import errors"
    - "Hosted Vercel `/connect` path lets a signed-in admin copy a working API key in ≤2 minutes with no 502s anywhere in the flow"
    - "LAN self-host at `http://192.168.x.x:3000` (NEXTAUTH_URL starts with `http:`) logs in, session persists, cookies land, /dashboard renders — CSP does not upgrade fetches, cookie is not Secure"
    - "Upgrading a 6-month-old DashClaw Postgres schema with legacy `api_keys` columns runs the setup migrations to completion without manual SQL patching"
  artifacts:
    - path: "__tests__/unit/lucide-exports.test.js"
      provides: "Regression test that imports { Github } from lucide-react and asserts typeof === 'function'"
    - path: "next.config.js"
      provides: "isTLS-gated CSP upgrade-insecure-requests / block-all-mixed-content / HSTS"
      contains: "const isTLS"
    - path: "app/api/auth/local/route.js"
      provides: "isHTTPS-gated Secure cookie flag + orgId env fallback in JWT payload"
      contains: "DASHCLAW_API_KEY_ORG"
    - path: "app/login/LocalPasswordForm.js"
      provides: "window.location.href hard redirect after login"
      contains: "window.location.href"
    - path: "middleware.js"
      provides: "isSelfHostModeEnabled() + verifyOrgExists() non-Neon self-host bypass"
      contains: "isSelfHostModeEnabled"
    - path: "scripts/migrate-api-keys-compat.mjs"
      provides: "CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS compat migration for api_keys"
    - path: "app/lib/setup/runtime-prerequisites.mjs"
      provides: "SETUP_MIGRATION_SCRIPTS entry for migrate-api-keys-compat.mjs"
      contains: "migrate-api-keys-compat"
  key_links:
    - from: "scripts/migrate-api-keys-compat.mjs"
      to: "app/lib/setup/runtime-prerequisites.mjs SETUP_MIGRATION_SCRIPTS array"
      via: "script path entry so deploy bootstrap actually runs it"
      pattern: "migrate-api-keys-compat"
    - from: "middleware.js verifyOrgExists"
      to: "DASHCLAW_MODE=self_host + !isNeon guard"
      via: "short-circuit that trusts migration-created org on self-host Postgres"
      pattern: "isSelfHostModeEnabled"
    - from: "next.config.js headers() CSP array"
      to: "NEXTAUTH_URL scheme check"
      via: "isTLS boolean wrapping upgrade-insecure-requests, block-all-mixed-content, and HSTS"
      pattern: "isTLS"
---

<objective>
Close the four known activation blockers documented in `.planning/research/SIGNAL.md` and detailed in `01-RESEARCH.md`. FIX-01 (lucide-react #71) is already resolved — this plan adds a regression guard only. FIX-02 (docs 502 / API key #31) is mostly resolved — this plan validates the hosted flow end-to-end. FIX-03 (Lief's LAN/CSP/HSTS fixes from `RyanTJoy/DashClaw` fork) and FIX-04 (Elpolini's migration compat from `elpolini/DashClaw` fork) are both fresh ports from forks with co-author credit. Both fork-ports touch `middleware.js` — Lief's and Elpolini's patches converge on the same self-host bypass, so they are combined in a single commit to avoid merge churn.

Purpose: a developer cloning DashClaw on a fresh Node 20 box can `npm ci && npm run build && npm run dev` and land on a working dashboard, and a self-hoster on a plain-HTTP LAN (or an upgrader from a legacy schema) no longer bounces off broken cookies, silently-upgraded fetches, or crashed migrations.

Output: regression test for lucide-react, surgical diffs to `next.config.js` / `app/api/auth/local/route.js` / `app/login/LocalPasswordForm.js` / `middleware.js` / `app/api/guard/route.js` / `app/api/keys/route.js` / `app/api/team/route.js` / `app/api/usage/route.js` / `scripts/migrate-multi-tenant.mjs` / `docker-compose.yml`, new file `scripts/migrate-api-keys-compat.mjs` wired into `SETUP_MIGRATION_SCRIPTS`, and a documented live-deploy smoke test for `/docs` and `/connect`.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/research/SIGNAL.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/codebase/CONVENTIONS.md
@.planning/codebase/TESTING.md
@CLAUDE.md

<interfaces>
<!-- Key contracts the executor needs. Do NOT go exploring beyond these files. -->

From `next.config.js` (headers() function, lines 16-70):
- CSP array built in `async headers()` with unconditional `'upgrade-insecure-requests'` and `'block-all-mixed-content'` entries (lines 31-32)
- HSTS header pushed unconditionally as `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` (lines 64-65)

From `app/api/auth/local/route.js` (verified in RESEARCH.md lines 48-59):
- POST handler: sets `dashclaw-local-session` cookie with `secure: process.env.NODE_ENV === 'production'`
- DELETE handler: clears the same cookie with the same secure flag
- JWT payload (line 39): `orgId: 'org_default'` hardcoded

From `app/login/LocalPasswordForm.js`:
- After successful `/api/auth/local` POST, calls `router.push('/dashboard')` (Next.js client router, does not force full cookie re-send)

From `middleware.js` (RESEARCH.md line 90):
- `verifyOrgExists()` function exists, has a local-Postgres self-host bypass at line 241, but does NOT have a non-Neon `DASHCLAW_MODE === 'self_host'` short-circuit
- Line 50 already defaults `DASHCLAW_MODE || 'self_host'`

From `app/lib/setup/runtime-prerequisites.mjs`:
- Exports `SETUP_MIGRATION_SCRIPTS` array
- Currently includes `migrate-action-records-compat.mjs` but NOT `migrate-api-keys-compat.mjs`

From the `RyanTJoy/DashClaw` fork (Lief) — canonical commits:
- `fa268c3` next.config.js isTLS gating — https://github.com/RyanTJoy/DashClaw/commit/fa268c3
- `108be08` secure-cookie conditional — https://github.com/RyanTJoy/DashClaw/commit/108be08
- `49c8ae3` cookie-auth + hard redirect + middleware bypass — https://github.com/RyanTJoy/DashClaw/commit/49c8ae3

From the `elpolini/DashClaw` fork — canonical commits:
- `dbf5463` feat(compat): harden self-host + legacy schema migrations — https://github.com/elpolini/DashClaw/commit/dbf5463
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Lucide-react regression guard + pin (FIX-01)</name>
  <files>package.json, __tests__/unit/lucide-exports.test.js</files>
  <read_first>
    - package.json (confirm current `lucide-react` version line)
    - __tests__/unit/validate.test.js (reference for Vitest import style + `describe`/`it` convention)
    - .planning/phases/01-foundation/01-RESEARCH.md (section A — lucide-react is already fixed, this is regression guard only)
  </read_first>
  <behavior>
    - Test 1: `expect(typeof Github).toBe('object' or 'function')` — importing { Github } from 'lucide-react' returns a truthy React component (not undefined)
    - Test 2: The exact five files using `Github` icon still import it without throwing at module-resolve time: PublicFooter.js, PublicNavbar.js, LoginClient.js, self-host/SetupTabs.js, toolkit/page.js
    - (Optional but recommended) Test 3: lucide-react resolved version in node_modules starts with `0.577` — catches an accidental major bump through the `^` range
  </behavior>
  <action>
    1. Open `package.json`. Change `"lucide-react": "^0.577.0"` to `"lucide-react": "0.577.0"` (drop the caret). This pins the working version and prevents the #71 regression class from recurring via a future minor bump that drops `Github`.
    2. Create `__tests__/unit/lucide-exports.test.js`:
       ```javascript
       import { describe, it, expect } from 'vitest';
       import * as LucideAll from 'lucide-react';
       import { Github } from 'lucide-react';

       describe('lucide-react exports (regression guard for issue #71)', () => {
         it('exports Github as a truthy React component', () => {
           expect(Github).toBeTruthy();
         });

         it('keeps all icons used across DashClaw importable from the root export', () => {
           // Icons referenced from app/components/PublicFooter.js, PublicNavbar.js,
           // app/login/LoginClient.js, app/self-host/SetupTabs.js, app/toolkit/page.js
           const required = ['Github'];
           for (const name of required) {
             expect(LucideAll[name], `lucide-react is missing ${name}`).toBeTruthy();
           }
         });

         it('resolves to the 0.577.x line (prevents accidental major bump)', async () => {
           const pkg = await import('lucide-react/package.json', { with: { type: 'json' } }).catch(() => null);
           // If the json import syntax isn't available in the runner, this block becomes a no-op; the first two checks are the real guard.
           if (pkg && pkg.default && pkg.default.version) {
             expect(pkg.default.version.startsWith('0.577')).toBe(true);
           }
         });
       });
       ```
    3. Do NOT touch any of the 5 consumer files (`PublicFooter.js`, `PublicNavbar.js`, `LoginClient.js`, `self-host/SetupTabs.js`, `toolkit/page.js`). RESEARCH.md section A verified they already import `Github` correctly.
  </action>
  <verify>
    <automated>npm test -- lucide-exports</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c '"lucide-react": "0.577.0"' package.json` returns `1` (no caret)
    - `__tests__/unit/lucide-exports.test.js` exists and contains the string `from 'lucide-react'` AND the string `regression guard for issue #71`
    - `npm test -- lucide-exports` exits 0 with at least 2 passing tests
    - `npm run build` (or `next build`) exits 0 on a fresh `rm -rf node_modules && npm ci` run — no lucide-react import errors in the output
  </acceptance_criteria>
  <done>pinned version, new regression test passes, clean `npm ci && npm run build` completes without lucide errors.</done>
</task>

<task type="auto">
  <name>Task 2: FIX-03 port Lief's LAN/CSP/HSTS/cookie fixes from RyanTJoy/DashClaw fork</name>
  <files>next.config.js, app/api/auth/local/route.js, app/login/LocalPasswordForm.js, __tests__/unit/security-headers.test.js</files>
  <read_first>
    - next.config.js (the full file — the `async headers()` function, lines 16-70, is the edit site)
    - app/api/auth/local/route.js (full file — both POST and DELETE handlers)
    - app/login/LocalPasswordForm.js (full file — find the `router.push('/dashboard')` call)
    - __tests__/unit/security-headers.test.js (existing security-headers test to know how to extend it)
    - .planning/phases/01-foundation/01-RESEARCH.md section C (exact diffs, commit SHAs fa268c3 / 108be08 / 49c8ae3)
  </read_first>
  <action>
    1. **`next.config.js`** — edit the `async headers()` function (lines 16-70). At the top of `headers()` before the `csp` array definition, add:
       ```javascript
       const isTLS = (process.env.NEXTAUTH_URL || '').startsWith('https');
       ```
       Replace lines 31-32 (the literal `'upgrade-insecure-requests'` and `'block-all-mixed-content'` entries inside the csp array) with:
       ```javascript
       ...(isTLS ? ['upgrade-insecure-requests', 'block-all-mixed-content'] : []),
       ```
       Replace the hardcoded `Strict-Transport-Security` object (lines 63-66) with a conditional construction: build the `headers:` array once without HSTS, then `if (isTLS) headers.push({ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' });` before the `return`. Keep the existing X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, and Content-Security-Policy headers exactly as they are. Add a short comment above the `isTLS` line: `// LAN self-host fix: TLS-conditional CSP + HSTS. Contributed by Lief (RyanTJoy).`

    2. **`app/api/auth/local/route.js`** — two edits:
       a. Add at the top of the file (after existing imports): `const isHTTPS = (process.env.NEXTAUTH_URL || '').startsWith('https');`
       b. In the POST handler, change `secure: process.env.NODE_ENV === 'production'` to `secure: isHTTPS` on the `dashclaw-local-session` cookie options.
       c. In the DELETE handler, make the same `secure: isHTTPS` change.
       d. In the JWT payload, change `orgId: 'org_default'` to `orgId: process.env.DASHCLAW_API_KEY_ORG || 'org_default'`.
       e. Add a short comment above the `isHTTPS` const: `// LAN self-host fix: cookie Secure flag follows scheme, not NODE_ENV. Contributed by Lief (RyanTJoy).`

    3. **`app/login/LocalPasswordForm.js`** — find the `router.push('/dashboard')` call after successful `/api/auth/local` POST. Replace with `window.location.href = '/dashboard';`. Add an inline comment: `// Hard redirect so the browser includes the fresh session cookie on /dashboard — router.push keeps the existing nav context and the cookie is not re-sent. Contributed by Lief (RyanTJoy).` Leave all other logic in this component (form state, error handling, loading state) untouched.

    4. **`__tests__/unit/security-headers.test.js`** — extend the existing suite with a new `describe('LAN self-host (plain HTTP) behavior', () => { ... })` block. Add two tests that call the `headers()` function (or a helper that invokes it) with `process.env.NEXTAUTH_URL` set to (a) `'http://192.168.1.50:3000'` and (b) `'https://dashclaw.example.com'`:
       - HTTP case: CSP string does NOT contain `upgrade-insecure-requests` or `block-all-mixed-content`, and the returned headers array does NOT include an entry with `key: 'Strict-Transport-Security'`
       - HTTPS case: CSP string DOES contain `upgrade-insecure-requests`, and the array DOES include an HSTS entry
       Use `beforeEach` / `afterEach` to save and restore `process.env.NEXTAUTH_URL`. If the existing test file already mocks/imports `next.config.js`, follow its pattern; otherwise `const config = require('../../next.config.js')` is acceptable since the config export is a plain object with an async `headers()` method.

    5. Git commit message for this task MUST include `Co-Authored-By: Lief (RyanTJoy) <RyanTJoy@users.noreply.github.com>` trailer.
  </action>
  <verify>
    <automated>npm test -- security-headers && npm run lint</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n 'isTLS' next.config.js` returns at least one line INSIDE the `async headers()` function
    - `grep -c 'upgrade-insecure-requests' next.config.js` returns exactly `1` (only inside the conditional spread, no stray copies)
    - `grep -n 'isHTTPS' app/api/auth/local/route.js` returns the const declaration line
    - `grep -c "secure: isHTTPS" app/api/auth/local/route.js` returns at least `2` (POST + DELETE)
    - `grep 'DASHCLAW_API_KEY_ORG' app/api/auth/local/route.js` returns a match in the JWT payload section
    - `grep "window.location.href = '/dashboard'" app/login/LocalPasswordForm.js` returns a match; `grep "router.push('/dashboard')" app/login/LocalPasswordForm.js` returns no match
    - `npm test -- security-headers` exits 0 with the new HTTP-LAN and HTTPS test cases passing
    - `npm run lint` exits 0
    - The git commit for this task contains a `Co-Authored-By: Lief (RyanTJoy)` trailer
  </acceptance_criteria>
  <done>All three Lief commits (`fa268c3`, `108be08`, `49c8ae3` — the client-side parts) are ported upstream, tests pass, lint clean, credit attached.</done>
</task>

<task type="auto">
  <name>Task 3: FIX-04 + shared middleware bypass — port Elpolini's migration compat (combined with Lief's middleware patch)</name>
  <files>middleware.js, app/api/guard/route.js, app/api/keys/route.js, app/api/team/route.js, app/api/usage/route.js, scripts/migrate-api-keys-compat.mjs, scripts/migrate-multi-tenant.mjs, app/lib/setup/runtime-prerequisites.mjs, docker-compose.yml</files>
  <read_first>
    - middleware.js (full file — find `verifyOrgExists`, line 50 `DASHCLAW_MODE` default, line 241 local-Postgres bypass)
    - app/api/guard/route.js, app/api/keys/route.js, app/api/team/route.js, app/api/usage/route.js (all four — find the existing org_default / isNeon guards)
    - scripts/migrate-multi-tenant.mjs (full file — find the `api_keys` section)
    - scripts/migrate-action-records-compat.mjs (pattern to model the new compat script on)
    - app/lib/setup/runtime-prerequisites.mjs (find the `SETUP_MIGRATION_SCRIPTS` export array)
    - docker-compose.yml (full file)
    - .planning/phases/01-foundation/01-RESEARCH.md section D (the Elpolini change inventory with the HIGH-priority subset flagged)
    - CLAUDE.md root → "Route SQL guardrail: no direct SQL in route files. Use repositories." — section D calls for adding column introspection INSIDE route files, but the guardrail forbids that. Put the introspection helpers in `app/lib/repositories/guard.repository.js` / `api-keys.repository.js` instead (or whichever repo currently services those routes) so the route files just call a repository method.
  </read_first>
  <action>
    1. **`middleware.js` — COMBINED Lief + Elpolini self-host bypass** (RESEARCH.md "Gotchas / Risks" #1 — port as ONE edit):
       - Add a helper function near the top-of-file env handling section:
         ```javascript
         function isSelfHostModeEnabled() {
           const mode = (process.env.DASHCLAW_MODE || 'self_host').toLowerCase();
           return mode === 'self_host';
         }
         ```
       - In `verifyOrgExists()`, BEFORE the existing DB lookup, add:
         ```javascript
         const dbUrl = process.env.DATABASE_URL || '';
         const isNeon = dbUrl.includes('.neon.tech') || dbUrl.includes('neon.tech');
         if (!isNeon && isSelfHostModeEnabled()) {
           // Self-host Postgres: migrations already created the org, trust the bootstrap.
           return true;
         }
         ```
       - In any org_default access guard in this file (look for `orgId === 'org_default'`), add a short-circuit: `if (isSelfHostModeEnabled()) return NextResponse.next();` so self-host deployments don't get blocked on the default org.
       - Improve the rate-limiting key (Elpolini's change): where the rate limiter currently uses IP only, change the key to `${ip}:${pathname}` — find the existing rate-limit section and the `ip` variable, then concatenate with `request.nextUrl.pathname`.
       - Commit trailer MUST include both: `Co-Authored-By: Lief (RyanTJoy) <RyanTJoy@users.noreply.github.com>` and `Co-Authored-By: Elpolini <elpolini@users.noreply.github.com>`.

    2. **`app/api/guard/route.js` — column compat + 42P01 catch** (via repository, NOT inline SQL):
       - Open the file. Find the repository call that currently fetches guard_decisions (if none exists and there is inline SQL, the route is already violating `route-sql:check`; in that case move the inline SQL to a repository function in `app/lib/repositories/guard.repository.js` as part of this task — keep this plan's scope to this one route).
       - In the repository function, before the SELECT, add an introspection helper:
         ```javascript
         async function hasReasonsColumn(sql) {
           const rows = await sql.query(
             "SELECT column_name FROM information_schema.columns WHERE table_name = 'guard_decisions' AND column_name IN ('reasons', 'reason')",
             []
           );
           return rows.some((r) => r.column_name === 'reasons');
         }
         ```
         Then build the SELECT to use `reasons` or `reason` based on that check (aliasing `reason AS reasons` in the legacy path so the route handler doesn't care which exists).
       - Wrap the repository call (or the SELECT) in a catch that specifically handles `error.code === '42P01'` (table not found — legacy schema pre-migration) and returns `{ decisions: [], stats: { total_24h: 0, blocks_24h: 0, warns_24h: 0, approvals_24h: 0 } }` instead of crashing.
       - Add `DASHCLAW_MODE=self_host` bypass in the route handler if there's an `org_default`-blocking guard: short-circuit before it using `isSelfHostModeEnabled()` from a shared helper (export it from `app/lib/selfHost.js` — create that file if it doesn't exist — and import from there in middleware.js AND this route).

    3. **`app/api/keys/route.js`** — add the same `isSelfHostModeEnabled()` bypass for the `org_default` block that currently prevents self-host users from fetching their keys. If there's a repository for keys, push the column-introspection fallback there (RESEARCH.md section D item 2 — check `information_schema.columns` for actual `api_keys` columns and build a dynamic SELECT if columns differ). Return empty `{ keys: [] }` if the table doesn't exist.

    4. **`app/api/team/route.js`** — add the `isSelfHostModeEnabled()` bypass for any `org_default` block. No schema compat needed here.

    5. **`app/api/usage/route.js`** — same pattern: `isSelfHostModeEnabled()` bypass for `org_default`. No schema compat needed here.

    6. **NEW FILE `scripts/migrate-api-keys-compat.mjs`**:
       - Model the shape on `scripts/migrate-action-records-compat.mjs`.
       - Connect via the existing DB URL (`process.env.DATABASE_URL`), using whichever pattern the existing compat scripts use (likely `postgres` or `@neondatabase/serverless` — follow the sibling script).
       - Run, in order: `CREATE TABLE IF NOT EXISTS api_keys (id TEXT PRIMARY KEY, ...)` with the current canonical column set, then `ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS <col> <type>` for every column that the current `app/api/keys/route.js` (or its repository) reads, then backfill nulls on new columns to sensible defaults, then `CREATE INDEX IF NOT EXISTS` for any indexes the current code expects.
       - Use `process.exitCode = 1` on failure + a `finally` block for connection teardown (Elpolini's pattern — matches the updated `migrate-action-records-compat.mjs` style).
       - Include a top-of-file comment: `// Compat migration for api_keys. Ported from Elpolini's fork (elpolini/DashClaw commit dbf5463).`

    7. **`scripts/migrate-multi-tenant.mjs`** — find the `api_keys` section. Wrap every implicit column assumption in `ADD COLUMN IF NOT EXISTS` guards. For every column the multi-tenant migration references on `api_keys`, issue `ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS <col> <type>` BEFORE the UPDATE/SELECT that relies on it. Also fix the `org_default` key ownership query per RESEARCH.md section D item 10.

    8. **`app/lib/setup/runtime-prerequisites.mjs`** — find the `SETUP_MIGRATION_SCRIPTS` array. Add `'migrate-api-keys-compat.mjs'` at the position BEFORE `'migrate-multi-tenant.mjs'` (the api_keys table must be compatible before multi-tenant rewrites touch it). Keep the existing entries untouched.

    9. **`docker-compose.yml`** — three edits from RESEARCH.md section D item 6:
       a. Add `env_file: .env.local` to the app service.
       b. Add explicit `build:` args (follow Elpolini's pattern — pull from the fork's exact diff).
       c. Change the Postgres service exposed port from `5432:5432` to `5433:5432` (container still listens on 5432 internally; the host-side 5433 avoids conflict with a system Postgres on dev machines).

    10. Git commit trailer MUST include: `Co-Authored-By: Elpolini <elpolini@users.noreply.github.com>`. If middleware.js is committed as part of this task (which it is), ALSO include `Co-Authored-By: Lief (RyanTJoy) <RyanTJoy@users.noreply.github.com>` — both authors contributed to that file.

    **DO NOT port:** Elpolini's `db.js` MockSql addition (RESEARCH.md Gotchas #7 — substantial, risky, dev-only, defer to a later phase), `dfcf560` docs/prompts env overrides (LOW priority, niche), `5c4d90a` bootstrap enrich (LOW priority, cosmetic), `072350e` lockfile sync (SKIP per research).
  </action>
  <verify>
    <automated>npm run lint && npm run route-sql:check && npm test -- --run middleware && npm test -- --run keys && npm test -- --run guard</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'isSelfHostModeEnabled' middleware.js` returns at least `2` (helper definition + at least one call site)
    - `grep -c 'isSelfHostModeEnabled' app/api/keys/route.js` returns at least `1`
    - `grep -c 'isSelfHostModeEnabled' app/api/team/route.js` returns at least `1`
    - `grep -c 'isSelfHostModeEnabled' app/api/usage/route.js` returns at least `1`
    - `grep -c 'isSelfHostModeEnabled' app/api/guard/route.js` returns at least `1`
    - `test -f scripts/migrate-api-keys-compat.mjs` exits 0
    - `grep 'migrate-api-keys-compat' app/lib/setup/runtime-prerequisites.mjs` returns at least one match
    - `grep -c 'ADD COLUMN IF NOT EXISTS' scripts/migrate-multi-tenant.mjs` is strictly greater than the count on `main` before this task
    - `grep '42P01' app/lib/repositories/guard.repository.js app/api/guard/route.js` returns a match in at least one file
    - `grep -c 'env_file' docker-compose.yml` returns at least `1`
    - `grep '5433:5432' docker-compose.yml` returns a match
    - `npm run route-sql:check` exits 0 (no new direct SQL in any modified `app/api/*/route.js` file)
    - `npm run lint` exits 0
    - `npm test -- --run middleware` AND `npm test -- --run keys` AND `npm test -- --run guard` all exit 0 (no regression of existing tests)
    - The commit for this task contains BOTH `Co-Authored-By: Lief (RyanTJoy)` and `Co-Authored-By: Elpolini` trailers
  </acceptance_criteria>
  <done>Elpolini's HIGH-priority compat changes are upstream, Lief's middleware bypass is merged with Elpolini's, the route SQL guardrail still passes, all tests green, co-author credit attached.</done>
</task>

<task type="auto">
  <name>Task 4: FIX-02 live Vercel smoke test for /docs and /connect (validate, do not re-implement)</name>
  <files>docs/smoke-tests/connect-api-key.md</files>
  <read_first>
    - .planning/phases/01-foundation/01-RESEARCH.md section B (current status: API key path IS fixed; docs 502 needs live validation only)
    - app/api/keys/route.js, app/api/keys/reveal/route.js (confirm the retrieval path exists)
    - app/docs/page.js (1999-line server component — just note its size; do not modify)
    - app/connect/ (find the onboarding entry point)
  </read_first>
  <action>
    1. Create a NEW file `docs/smoke-tests/connect-api-key.md` containing the exact manual steps a human (or an auditor on the next phase) walks through on the hosted Vercel deployment. Keep it a plain checklist — no code, no new scripts. Content sections (use H2 headers):
       - `## Scope`: "Validates FIX-02 — `/docs` loads without 502, and the `/connect` → first API key path completes in ≤2 minutes on a fresh session. Evidence for ROADMAP Phase 1 success criterion #2."
       - `## Environment`: "Hosted Vercel deployment at `$DASHCLAW_LIVE_URL` (the live URL Wes points at in his dogfood setup — see `docs/dogfood-setup.md` if it exists)."
       - `## Steps` (numbered checklist):
         1. Open a fresh browser / incognito window (no existing DashClaw cookies).
         2. Navigate to `$DASHCLAW_LIVE_URL/docs`. Expect: the docs page renders within 5 seconds, status 200. FAIL if: 502, 504, or blank page.
         3. Open DevTools Network tab. Reload `/docs` once. Expect: all requests return 2xx or 304. FAIL if: any 5xx response.
         4. Navigate to `$DASHCLAW_LIVE_URL/connect`. Expect: the onboarding page renders, shows a "Get API key" or equivalent CTA.
         5. Follow the onboarding flow as a first-time user. Sign in (or bootstrap the admin) as needed.
         6. Reach the step where the API key is revealed / copyable. Click to copy. FAIL if: 502, 401, or the key is masked with no reveal affordance.
         7. Paste the copied key into a terminal and run a verification request (use the existing health/guard endpoint, e.g. `curl -H "Authorization: Bearer <key>" $DASHCLAW_LIVE_URL/api/health` — expect 200).
         8. Stop the timer. Expect: total elapsed time from step 4 to step 7 is ≤ 2 minutes.
       - `## Pass criteria`: "All 8 steps green. Total elapsed ≤ 2 minutes. No 5xx anywhere in the Network tab."
       - `## If /docs still 502s`: "Likely a Vercel cold-start timeout on the 1999-line `app/docs/page.js` server component (see RESEARCH.md section B). Do NOT split the file in this phase — log the timing in this doc and open a follow-up plan in a later phase. The API key path must still pass independently."
       - `## Evidence`: "Paste the curl output and the elapsed time into this document under a `### Run YYYY-MM-DD` section when the smoke test is executed. Commit the result to close FIX-02."
    2. No code changes. No new routes. No modifications to `app/docs/page.js`, `app/api/keys/route.js`, or `app/api/keys/reveal/route.js` — RESEARCH.md section B verified the API key retrieval path is already wired correctly as of commit `6bb2a16d`.
    3. Do NOT touch `README.md`, `app/page.js`, or any other file in git status. This task creates exactly one new file.
  </action>
  <verify>
    <automated>test -f docs/smoke-tests/connect-api-key.md && grep -q "FIX-02" docs/smoke-tests/connect-api-key.md && grep -q "≤ 2 minutes" docs/smoke-tests/connect-api-key.md</automated>
  </verify>
  <acceptance_criteria>
    - `test -f docs/smoke-tests/connect-api-key.md` exits 0
    - The file contains the strings `FIX-02`, `## Steps`, `/connect`, `/docs`, `≤ 2 minutes`, and `If /docs still 502s`
    - The file does NOT contain a "Run YYYY-MM-DD" section yet (that gets filled in when the smoke test is actually executed, which is out of scope for this phase's autonomous work — it's a human action but does not gate plan completion)
    - No other files modified (`git status` shows only this one new file)
  </acceptance_criteria>
  <done>Live smoke test checklist exists as the artifact that closes FIX-02 at validation time. FIX-02 does not require code changes.</done>
</task>

</tasks>

<verification>
- `npm ci && npm run build` exits 0 on Node 20 with no lucide-react errors
- `npm test` exits 0; `lucide-exports.test.js` and the new `security-headers.test.js` cases pass
- `npm run lint` exits 0
- `npm run route-sql:check` exits 0 (repository pattern preserved)
- `npm run openapi:check` and `npm run api:inventory:check` exit 0 if routes were modified
- `grep -rn 'Co-Authored-By: Lief\|Co-Authored-By: Elpolini' $(git log --oneline -20 | head -20 | cut -d' ' -f1 | xargs -I{} git log -1 --format=%B {}) 2>/dev/null` shows both trailers in the commits produced by Tasks 2 and 3
- `docs/smoke-tests/connect-api-key.md` exists and contains all required sections
- No files modified outside the declared `files_modified` list (verify with `git status`)
</verification>

<success_criteria>
- FIX-01: Regression test passes, `lucide-react` pinned to `0.577.0`, clean-install build green
- FIX-02: Live smoke-test doc exists and describes an unambiguous pass/fail run of `/docs` and `/connect`
- FIX-03: `next.config.js`, `app/api/auth/local/route.js`, `app/login/LocalPasswordForm.js`, and `middleware.js` all carry Lief's LAN fixes with co-author credit
- FIX-04: `scripts/migrate-api-keys-compat.mjs` exists and is wired into `SETUP_MIGRATION_SCRIPTS`, `middleware.js` has the combined self-host bypass, the four routes have self-host bypasses, `docker-compose.yml` has the env_file + port fix, commit has Elpolini co-author credit
- All route SQL goes through repository layer (no new direct SQL in `app/api/*/route.js`)
- All tests pass, lint clean, no files modified outside `files_modified`
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` following the standard SUMMARY template. Include: list of requirements closed (FIX-01, FIX-02, FIX-03, FIX-04), commits produced with co-author trailers, evidence that the route SQL guardrail still passes, and any follow-ups (e.g., if `/docs` 502 smoke test was actually run and passed, note the timing; if it was not run, note that it's deferred to the next deploy window).
</output>
