# v7.0 Phase 4 — Dashboard UI Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the Free tier dashboard UI (overview, runs list, run detail, billing) + the deferred `/cli-auth` page from Phase 2.3 + public share-by-URL. After this phase, autopilot.dev/dashboard works end-to-end and CLI login is fully wired.

**Architecture:** Server components for data-fetching pages, client components only where interactivity is required (`/cli-auth`, visibility toggle, billing buttons, event replay). `/cli-auth` POSTs the minted API key to the CLI's loopback listener using `mode: 'cors'`; the Phase 2.3 listener gains an OPTIONS handler. Storage stays fully private — all reads go through `GET /api/dashboard/runs/:runId/artifact`, which checks visibility/ownership before issuing a 60s signed URL. Public share-by-URL relies on a new RLS policy added to the migration.

**Tech Stack:** Next.js 16 App Router (apps/web from Phase 2.1), `@supabase/ssr` server client, Tailwind v4 utility classes, Vitest + @testing-library/react + jsdom, no new heavy deps.

**Spec:** `docs/specs/v7.0-phase4-dashboard-ui.md` (PR #124).

**Repo:** `/Users/alexledbetter/Downloads/claude-autopilot` (NOT delegance-app).

**IMPORTANT — skip dev-env-dependent verification.** Operator-only:
- Running `/migrate` (operator post-merge).
- Real Stripe API calls (use vi.mock).
- Real Vercel deploy.
- Real Supabase project (use stub).

**Codex pass on plan folded inline (3 CRITICAL + 5 WARNING + 2 NOTE accepted; recurring Vercel/ECS confusion dismissed):**
- CRITICAL: Public-share anon SELECT widened risk surface — anyone with the anon key could `SELECT * FROM runs WHERE visibility='public'` and enumerate every public run + read all selectable columns. Mitigation: column-level GRANT to anon (only safe fields) + the policy. Done in Task 1 migration.
- CRITICAL: Reaffirmed in Task 8 — `/cli-auth` Server Component MUST call `validateCallbackUrl(cb)` from Phase 2.3's helper FIRST (loopback only, port 56000-56050) and refuse to render the claim button if invalid. API key NEVER passes through Server Component as a prop — it's minted client-side via fetch after hydration. Test 11 in spec asserts the validator is called.
- CRITICAL: Reaffirmed in Task 8 + test 26 — middleware CSP for `/cli-auth` MUST include `connect-src 'self' http://127.0.0.1:* http://localhost:*`. The integration test asserts this exact header value, not just that some CSP exists. Without this, the loopback POST fails silently.
- WARNING: assertSameOrigin compares against `loadPublicBillingConfig().AUTOPILOT_PUBLIC_BASE_URL` (constructed expected origin), NOT `new URL(req.url).origin` (which would be the internal container URL behind a proxy). Documented in Task 2.
- WARNING: finalize metadata is display-only; reaffirmed in Task 7 + test 29 covers malformed-value sanitization.
- WARNING: artifact route — chunk path derived ONLY from DB-trusted values via `chunkPath()` helper from Phase 2.2; raw `path` query param NEVER accepted. Reaffirmed in Task 6.
- WARNING: deployment ordering — operator MUST run `/migrate` BEFORE the code deploy so finalize writes don't 500 on missing columns. Documented in PR body + finalize handler wraps the cost/duration/status fields in a try/catch that drops them on column-not-found error (graceful degradation during the rollout window).
- WARNING: RLS/storage tests are stubbed in Task 6; full Supabase integration is operator-only. Acknowledged; Phase 5 hardening backlog adds dedicated RLS tests in `tests/rls/`.
- NOTE: CLI listener CORS — `Access-Control-Allow-Origin` returns the EXACT configured `AUTOPILOT_PUBLIC_BASE_URL` value, not `*`. Test in Task 4 asserts this.
- NOTE: public route uses `runs.id` UUID directly (non-guessable, no separate share token). Documented in Task 11.

---

## File Structure

| Path | Purpose |
|---|---|
| `data/deltas/20260508120000_phase4_runs_metadata.sql` (NEW) | runs.cost_usd + duration_ms + run_status + cost-chart indexes + anon SELECT policy. |
| `apps/web/middleware.ts` (MODIFIED) | Add `/cli-auth` security headers (no-store, CSP, no-referrer). Update matcher if needed. |
| `apps/web/lib/auth/redirect.ts` (MODIFIED) | safeRedirect allowlist accepts `/cli-auth`; preserves query string. |
| `apps/web/lib/dashboard/same-origin.ts` (NEW) | `assertSameOrigin(req)` shared helper. |
| `apps/web/app/api/dashboard/runs/[runId]/visibility/route.ts` (NEW) | PATCH; owner-only; assertSameOrigin. |
| `apps/web/app/api/dashboard/runs/[runId]/artifact/route.ts` (NEW) | GET; signed-URL minter with viz check; bounded seq. |
| `apps/web/app/api/dashboard/api-keys/mint/route.ts` (MODIFIED) | Add assertSameOrigin to cookie-auth path. |
| `apps/web/app/api/dashboard/api-keys/revoke/route.ts` (MODIFIED) | Add assertSameOrigin to cookie-auth path. |
| `apps/web/app/api/dashboard/billing/checkout/route.ts` (MODIFIED) | Add assertSameOrigin. |
| `apps/web/app/api/dashboard/billing/portal/route.ts` (MODIFIED) | Add assertSameOrigin. |
| `apps/web/app/api/runs/[runId]/finalize/route.ts` (MODIFIED) | Persist cost_usd/duration_ms/run_status via sanitizeFinalizeMetadata. |
| `apps/web/app/dashboard/layout.tsx` (NEW) | Auth-gated shell with sidebar. |
| `apps/web/app/dashboard/page.tsx` (NEW) | Overview. |
| `apps/web/app/dashboard/runs/page.tsx` (NEW) | Run list (paginated). |
| `apps/web/app/dashboard/runs/[runId]/page.tsx` (NEW) | Run detail. |
| `apps/web/app/dashboard/billing/page.tsx` (NEW) | Plan + caps + usage. |
| `apps/web/app/dashboard/billing/success/page.tsx` (NEW) | Post-checkout polling. |
| `apps/web/app/cli-auth/page.tsx` (NEW) | Server-validated; client-component CLI claim. |
| `apps/web/app/cli-auth/_components/CliAuthClaim.tsx` (NEW) | Client component for the loopback POST. |
| `apps/web/app/runs/[runShareId]/page.tsx` (NEW) | Public share — server-side anon Supabase. |
| `apps/web/components/dashboard/{RunListItem,EventReplay,StateInspector,CostChart,PlanCard,VisibilityToggle}.tsx` (NEW) | UI components. |
| `apps/web/__tests__/api/dashboard/visibility.test.ts` (NEW) | 4 tests. |
| `apps/web/__tests__/api/dashboard/artifact.test.ts` (NEW) | 6 tests. |
| `apps/web/__tests__/api/dashboard/artifact-rls.test.ts` (NEW) | 3 tests. |
| `apps/web/__tests__/lib/cli-auth/validate.test.ts` (NEW) | 3 tests. |
| `apps/web/__tests__/api/upload-session.test.ts` (MODIFIED) | +1 finalize-persists test. |
| `apps/web/__tests__/integration/dashboard-pages.test.tsx` (NEW) | 5 page-render tests. |
| `apps/web/__tests__/components/cost-chart.test.tsx` (NEW) | 2 tests. |
| `apps/web/__tests__/integration/cli-auth-headers.test.ts` (NEW) | 1 test (security headers). |
| `apps/web/__tests__/integration/cli-auth-redirect.test.ts` (NEW) | 1 test (URLSearchParams round-trip). |
| `apps/web/__tests__/api/dashboard/visibility-csrf.test.ts` (NEW) | 1 test (Origin mismatch). |
| `apps/web/__tests__/api/finalize-sanitize.test.ts` (NEW) | 1 test (malformed run_status → null). |
| `apps/web/__tests__/api/dashboard/artifact-seq-bounds.test.ts` (NEW) | 1 test (seq out of range). |
| `tests/dashboard/login.test.ts` (MODIFIED) | +1 OPTIONS preflight regression. |
| `src/cli/dashboard/login.ts` (MODIFIED) | OPTIONS handler + Access-Control-Allow-Origin response on POST. |
| `src/dashboard/config.ts` (MODIFIED) | Read AUTOPILOT_PUBLIC_BASE_URL with AUTOPILOT_DASHBOARD_BASE_URL fallback + deprecation warn. |
| `package.json` (root, MODIFIED) | Bump 6.3.0-pre.5 → 6.3.0-pre.6. |
| `apps/web/package.json` (MODIFIED) | Add @testing-library/react@^16, @testing-library/jest-dom@^6, jsdom@^25. |
| `apps/web/vitest.config.ts` (MODIFIED) | Add jsdom env for *.test.tsx; setupFiles for jest-dom matchers. |
| `apps/web/.env.example` (MODIFIED) | Standardize AUTOPILOT_PUBLIC_BASE_URL note. |
| `.env.example` (MODIFIED) | Same. |
| `CHANGELOG.md` (MODIFIED) | New 6.3.0-pre.6 entry. |
| `docs/v6/migration-guide.md` (MODIFIED) | Phase 4 section. |
| `docs/specs/v7.0-phase4-dashboard-ui.md` (MODIFIED at merge) | Status section. |

---

## Task 1: Migration

**Files:**
- Create: `data/deltas/20260508120000_phase4_runs_metadata.sql`

- [ ] Per spec — runs.cost_usd / duration_ms / run_status with CHECKs + cost chart indexes + RLS policy `runs_select_public`.

**Codex plan-pass CRITICAL #1 — narrow column grant for anon.** RLS is row-level, not column-level. Without explicit column grants, an attacker with the anon key could `SELECT *` and read internal metadata (user_id, organization_id, source_verified, storage paths). Add to migration:

```sql
-- Codex plan-pass CRITICAL — anon gets SELECT only on safe public columns,
-- NOT the whole row. organization_id / user_id / events_index_path /
-- state_blob_path / events_chain_root etc. stay restricted.
REVOKE ALL ON runs FROM anon;
GRANT SELECT (
  id, source_verified, cost_usd, duration_ms, run_status,
  total_bytes, created_at, visibility
) ON runs TO anon;
-- Authenticated keeps its existing GRANTs (Phase 1 schema).
```

The signed-URL minter is the gate for events.ndjson + state.json reads — anon can identify a public run via this select, then fetch artifacts via the minter (which re-checks visibility).

Commit.

---

## Task 2: assertSameOrigin shared helper + tests

**Files:**
- Create: `apps/web/lib/dashboard/same-origin.ts`
- Create: `apps/web/__tests__/lib/dashboard/same-origin.test.ts`

- [ ] Implement per spec. 3 tests: matching origin → ok; missing origin → fail; mismatched origin → fail.
- [ ] Commit.

---

## Task 3: Wire assertSameOrigin into existing mutating routes

**Files:**
- Modify: `apps/web/app/api/dashboard/api-keys/{mint,revoke}/route.ts`
- Modify: `apps/web/app/api/dashboard/billing/{checkout,portal}/route.ts`
- Create: `apps/web/__tests__/api/dashboard/visibility-csrf.test.ts` (test 28 lives here pre-route-creation; will assert against the new visibility route once Task 5 lands)

For each route:
- Branch on auth mode. Cookie path → call `assertSameOrigin(req)` first; reject with 403 on mismatch. API-key bearer path → skip the check.
- Mint already cookie-only → unconditional check.
- Revoke accepts both → conditional.
- Checkout, portal cookie-only → unconditional check.

Tests: extend each route's existing test file with 1 origin-mismatch case (4 added tests across mint/revoke/checkout/portal). Commit.

---

## Task 4: Update safeRedirect allowlist + Phase 2.3 CLI listener CORS

**Files:**
- Modify: `apps/web/lib/auth/redirect.ts` — accept `/cli-auth` with query preserved.
- Modify: `apps/web/__tests__/lib/auth/redirect.test.ts` — add 2 cases: `/cli-auth?cb=&nonce=` accepted; redirect target preserves query.
- Modify: `src/cli/dashboard/login.ts` — OPTIONS handler + CORS headers on POST.
- Modify: `tests/dashboard/login.test.ts` — add 1 test asserting OPTIONS returns CORS preflight headers; existing POST test asserts response now includes `Access-Control-Allow-Origin`.
- Modify: `src/dashboard/config.ts` — `getAutopilotBaseUrl()` reads AUTOPILOT_PUBLIC_BASE_URL with AUTOPILOT_DASHBOARD_BASE_URL fallback + warn-once deprecation.

Commit per file group.

---

## Task 5: Visibility PATCH route + tests

**Files:**
- Create: `apps/web/app/api/dashboard/runs/[runId]/visibility/route.ts`
- Create: `apps/web/__tests__/api/dashboard/visibility.test.ts`

Per spec — PATCH; assertSameOrigin; resolveCallerFromCookie (Supabase SSR); ownership check; UPDATE only `visibility` field. 4 tests: owner→public 200, owner→private 200, non-owner 404, anon 401. Plus the visibility-csrf test (test 28) lives here. Commit.

---

## Task 6: Artifact signed-URL minter + tests

**Files:**
- Create: `apps/web/app/api/dashboard/runs/[runId]/artifact/route.ts`
- Create: `apps/web/__tests__/api/dashboard/artifact.test.ts`
- Create: `apps/web/__tests__/api/dashboard/artifact-rls.test.ts`
- Create: `apps/web/__tests__/api/dashboard/artifact-seq-bounds.test.ts`

Per spec — GET; visibility/ownership check; chunk seq bounded against upload_session_chunks count; 60s signed URL via `storage.from('run-uploads').createSignedUrl(path, 60)`. 

Tests:
- artifact.test.ts: 6 tests per spec (owner manifest/chunk/state, anon on public, anon on private, invalid kind).
- artifact-rls.test.ts: 3 tests — private storage path direct fetch denied; public via signed URL succeeds; owner via signed URL succeeds. Stub the storage client to return what it would if the bucket policies were applied.
- artifact-seq-bounds.test.ts: 1 test — seq=999 with 2 chunks → 422.

Stub additions for supabase-stub: `.storage.from(BUCKET).createSignedUrl(path, ttl)` returns `{ data: { signedUrl: 'http://stub/signed/...' }, error: null }`. Add `.from('upload_session_chunks').select('seq', { count: 'exact', head: true })` support — return count of matching rows.

Commit.

---

## Task 7: Finalize handler — persist cost/duration/status

**Files:**
- Modify: `apps/web/app/api/runs/[runId]/finalize/route.ts`
- Create: `apps/web/lib/runs/sanitize-finalize-metadata.ts` — pure helper from spec
- Modify: `apps/web/__tests__/api/upload-session.test.ts` — +1 test asserting cost_usd/duration_ms/run_status persisted from valid state.json
- Create: `apps/web/__tests__/api/finalize-sanitize.test.ts` — +1 test (malformed run_status → null)

Implement `sanitizeFinalizeMetadata` per spec; insert into the existing `runs.update(...)` payload.

**Codex plan-pass WARNING — graceful degradation during rollout window.** Operator runs `/migrate` post-merge; between code-deploy and migration-apply, finalize would 500 on the new columns. Wrap the cost/duration/status fields in a separate UPDATE that's allowed to fail silently:

```typescript
// After the main runs.update succeeds:
try {
  const sanitized = sanitizeFinalizeMetadata(body.stateJson);
  await supabase.from('runs').update(sanitized).eq('id', p.runId);
} catch (err) {
  // Pre-migration: columns don't exist yet. Drop the metadata write;
  // don't fail the whole finalize. Operators run /migrate within minutes.
  console.warn('[finalize] metadata write skipped (columns may not exist yet):', (err as Error).message);
}
```

Commit.

---

## Task 8: cli-auth page + client component + middleware headers

**Files:**
- Modify: `apps/web/middleware.ts` — add `/cli-auth` matcher and security headers
- Create: `apps/web/app/cli-auth/page.tsx`
- Create: `apps/web/app/cli-auth/_components/CliAuthClaim.tsx`
- Create: `apps/web/__tests__/lib/cli-auth/validate.test.ts` — 3 tests
- Create: `apps/web/__tests__/integration/cli-auth-headers.test.ts` — 1 test (response headers)
- Create: `apps/web/__tests__/integration/cli-auth-redirect.test.ts` — 1 test (URLSearchParams round-trip)

Per spec. The Server Component validates cb + nonce + auth; if unauth, builds `next` via URLSearchParams. Authenticated render delegates to `<CliAuthClaim>` client component which holds the apiKey in state + runs the loopback POST + retry on failure. Commit.

---

## Task 9: Dashboard layout + overview + runs list

**Files:**
- Create: `apps/web/app/dashboard/layout.tsx` — auth-gated, sidebar, user menu
- Create: `apps/web/app/dashboard/page.tsx` — overview
- Create: `apps/web/app/dashboard/runs/page.tsx` — paginated list (20/page)
- Create: `apps/web/components/dashboard/RunListItem.tsx`
- Create: `apps/web/components/dashboard/PlanCard.tsx`
- Create: `apps/web/components/dashboard/CostChart.tsx`
- Create: `apps/web/__tests__/components/cost-chart.test.tsx` — 2 tests

Server components fetch via createServerSupabase. Pagination uses `range(offset, offset+19)`. CostChart renders inline SVG (no chart library). Commit.

---

## Task 10: Run detail + EventReplay + StateInspector + VisibilityToggle

**Files:**
- Create: `apps/web/app/dashboard/runs/[runId]/page.tsx`
- Create: `apps/web/components/dashboard/EventReplay.tsx` — client; manifest-driven, lazy chunks
- Create: `apps/web/components/dashboard/StateInspector.tsx` — client; recursive tree
- Create: `apps/web/components/dashboard/VisibilityToggle.tsx` — client; PATCH to visibility route

EventReplay fetches `/api/dashboard/runs/:runId/artifact?kind=manifest`, then chunks lazily. Hard cap 1000 events (across all loaded chunks) for MVP; "Load more" button continues. Commit.

---

## Task 11: Public share-by-URL `/runs/[runShareId]`

**Files:**
- Create: `apps/web/app/runs/[runShareId]/page.tsx` — server component, anon Supabase client
- Create: `apps/web/components/public/PublicRunView.tsx` — read-only view

Uses `createClient(URL, ANON_KEY, { auth: { persistSession: false } })`. Anon SELECT policy from migration handles RLS. Commit.

---

## Task 12: Billing pages

**Files:**
- Create: `apps/web/app/dashboard/billing/page.tsx` — current plan + caps + usage; Upgrade/Manage buttons
- Create: `apps/web/app/dashboard/billing/success/page.tsx` — polls /api/dashboard/me

Server reads entitlements + computes usage via the Phase 3 RPCs. Buttons POST to checkout/portal endpoints. Commit.

---

## Task 13: Dashboard pages integration tests

**Files:**
- Create: `apps/web/__tests__/integration/dashboard-pages.test.tsx`

Add jsdom + @testing-library/react + @testing-library/jest-dom deps. Update `apps/web/vitest.config.ts` to use jsdom env for `**/*.test.tsx`. Tests 15-19 from spec. Commit.

---

## Task 14: Docs + version bump

**Files:**
- Modify: `package.json`, `apps/web/package.json`, `CHANGELOG.md`, `docs/v6/migration-guide.md`, `apps/web/.env.example`, `.env.example`

Bump 6.3.0-pre.5 → 6.3.0-pre.6. CHANGELOG entry per spec template. Migration guide section. Commit.

---

## Final verification

- [ ] `cd apps/web && npx vitest run` — all green (120+ → ~150).
- [ ] `npm test -- tests/dashboard` — all green (1594 → ~1595).
- [ ] `cd apps/web && npx tsc --noEmit` — clean.
- [ ] `npx tsc --noEmit -p tsconfig.json` — clean.
- [ ] `git push -u origin feat/v7.0-phase4-dashboard-ui`.
- [ ] `gh pr create --base master` titled `feat(v7.0-phase4): dashboard UI + cli-auth page`. Body: spec link (PR #124), plan link, operator follow-ups (run /migrate; no new env vars but document AUTOPILOT_PUBLIC_BASE_URL standardization).

---

## Self-review

- [x] Spec coverage: all 30 tests in spec mapped to tasks 2 (3) + 3 (4) + 4 (3) + 5 (4) + 6 (10) + 7 (2) + 8 (5) + 9 (2) + 13 (5) — sums above 30 because some tasks add more than spec target. Acceptable over-coverage.
- [x] Migration mirrors spec SQL exactly including the new RLS policy.
- [x] No "TBD" placeholders. Tasks 9-12 trim component implementation detail; agent fills from spec which has full code blocks.
- [x] Type consistency: `RunRow`, `EntitlementResult`, `Tier` reused from earlier phases.
- [x] Hard constraints from spec re-stated in task 8 (cli-auth) and task 6 (artifact minter).
- [x] Skip list at top.

**Codex on plan dismissed:** Vercel-vs-ECS NOTE (recurring confusion).
