---
phase: 19-classification-engine
plan: 02
type: execute
wave: 2
depends_on: ["19-01"]
files_modified:
  - app/api/phishing/campaigns/[id]/classify/route.ts
autonomous: true
requirements: [CLASSIFY-05]

must_haves:
  truths:
    - "POST /api/phishing/campaigns/{id}/classify calls requirePermission('phishing','analyze') and rejects unauthenticated (401) / unauthorized (403) requests"
    - "A malformed (non-UUID) campaign id returns 400 before any DB query"
    - "A valid authorized request runs classifyCampaign and returns the verdict payload (id, campaignId, verdict, confidence, summary, reasons, recommendedActions, requiresApproval)"
    - "An unknown campaign id returns 404"
  artifacts:
    - path: "app/api/phishing/campaigns/[id]/classify/route.ts"
      provides: "POST classify route handler"
      exports: ["POST"]
      min_lines: 30
  key_links:
    - from: "app/api/phishing/campaigns/[id]/classify/route.ts"
      to: "requirePermission('phishing','analyze')"
      via: "auth-utils early-return"
      pattern: "requirePermission\\('phishing', 'analyze'\\)"
    - from: "app/api/phishing/campaigns/[id]/classify/route.ts"
      to: "classifyCampaign"
      via: "import from @/lib/services/campaign-classifier"
      pattern: "classifyCampaign"
---

<objective>
Add the `POST /api/phishing/campaigns/{id}/classify` route — the on-demand (re-)trigger for
campaign classification (D-02). It enforces the Phase 18 auth convention (`requirePermission('phishing','analyze')`,
the SAME action as `/analyze`, not a new permission), validates the campaign id as a UUID (V5),
delegates to `classifyCampaign(id)` from Plan 01, and returns the flat camelCase verdict payload.

Purpose: An operator can (re-)classify a campaign through a properly access-controlled endpoint.
Output: `app/api/phishing/campaigns/[id]/classify/route.ts`.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-classification-engine/19-CONTEXT.md
@.planning/phases/19-classification-engine/19-PATTERNS.md
@.planning/phases/19-classification-engine/19-VALIDATION.md

<interfaces>
<!-- Contract from Plan 01 (wave 1). Import directly. -->
From lib/services/campaign-classifier.ts:
```typescript
export interface ClassifyResult {
  id: string; campaignId: string;
  verdict: 'SPAM' | 'UNWANTED' | 'THREAT';
  confidence: number; summary: string;
  reasons: string[]; recommendedActions: string[];
  requiresApproval: boolean; createdAt: string;
}
export function classifyCampaign(campaignId: string): Promise<ClassifyResult>;
```

Auth (lib/permissions.ts): `phishing: ["read","analyze","approve","remediate"]`; `analyze`
is granted to super-admin + admin only (a plain `user` role has `read` only → expect 403).

UUID guard (from app/api/phishing/campaigns/[id]/route.ts):
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: POST /api/phishing/campaigns/[id]/classify route handler</name>
  <files>app/api/phishing/campaigns/[id]/classify/route.ts</files>
  <read_first>
    - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact auth early-return: `const { error } = await requirePermission('phishing','analyze'); if (error) return error;`; try/catch → 500 with message; [PHISHING-ANALYZE] log-prefix convention)
    - app/api/phishing/campaigns/[id]/route.ts (UUID_RE guard + 400; params is Promise<{ id: string }>; flat camelCase response shape; [PHISHING-CAMPAIGN-DETAIL] prefix; 404 when campaign not found)
    - lib/permissions.ts (confirm phishing 'analyze' action already exists — do NOT add a new action)
    - lib/services/campaign-classifier.ts (classifyCampaign signature + ClassifyResult from Plan 01)
    - .planning/phases/19-classification-engine/19-PATTERNS.md (route/controller section — verbatim analog mapping)
  </read_first>
  <action>
    Create `app/api/phishing/campaigns/[id]/classify/route.ts` exporting `async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> })`.

    Body, in order:
    1. `const { error } = await requirePermission('phishing', 'analyze'); if (error) return error;` — same `'analyze'` action as the existing `/analyze` route (Phase 18 D-06; T-19-04 access-control). Import `requirePermission` from `@/lib/auth-utils`.
    2. `const { id } = await params;` then the `UUID_RE` guard copied verbatim from `campaigns/[id]/route.ts` — return `NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 })` on failure, BEFORE any DB access (V5 / T-19-05 tampering guard). Use `campaigns/[id]/route.ts`'s UUID regex, NOT `analyze/route.ts`'s numeric `Number.isFinite` check (this id is a campaign UUID, not a ticket_id).
    3. `try { ... } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); return NextResponse.json({ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 }); }`.
    4. Inside try: verify the campaign exists first — `SELECT id FROM campaigns WHERE id = $1` (postgresClient default import from `@/lib/services/postgres-client`); if no row, return `NextResponse.json({ error: 'Campaign not found' }, { status: 404 })` (mirrors campaigns/[id]/route.ts 404). Then `const result = await classifyCampaign(id);` (import from `@/lib/services/campaign-classifier`) and `return NextResponse.json(result);` — `ClassifyResult` is already flat camelCase, no re-mapping needed.

    Use path-alias `@/lib/...` imports (route-handler convention), NOT the sibling-filename imports used inside lib/services. Do not add the route to middleware public paths — `/api/phishing/*` is authenticated (this is intentional, T-19-04).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
    <automated>test -f "app/api/phishing/campaigns/[id]/classify/route.ts" &amp;&amp; grep -q "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts" &amp;&amp; echo AUTH_OK</automated>
    <human-check>
      Against a dev server (npm run dev, port 3100) with a known campaign UUID:
      1. `curl -X POST http://localhost:3100/api/phishing/campaigns/{uuid}/classify` with a valid admin/super-admin session cookie → 200 with verdict/confidence/summary/reasons/recommendedActions/requiresApproval
      2. Same with NO session cookie → 401
      3. Same with a plain `user`-role session cookie → 403 (analyze not granted to user)
      4. `curl -X POST http://localhost:3100/api/phishing/campaigns/not-a-uuid/classify` (authed) → 400
      5. Valid UUID that is not a real campaign → 404
    </human-check>
  </verify>
  <acceptance_criteria>
    - `npx tsc --noEmit --pretty` exits 0
    - `grep -c "requirePermission('phishing', 'analyze')" "app/api/phishing/campaigns/[id]/classify/route.ts"` === 1 (T-19-04)
    - `grep -q "UUID_RE" "app/api/phishing/campaigns/[id]/classify/route.ts"` — UUID guard present before DB access (T-19-05)
    - `grep -q "classifyCampaign" "app/api/phishing/campaigns/[id]/classify/route.ts"` — delegates to the Plan 01 service
    - Manual curl (human-check): 200 for authed admin, 401 no-session, 403 user-role, 400 malformed id, 404 unknown campaign
  </acceptance_criteria>
  <done>Route compiles, enforces analyze permission + UUID validation, delegates to classifyCampaign, and returns the verdict payload; manual auth curl matrix passes.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → API route | Unauthenticated/unauthorized HTTP request + attacker-controlled `id` path param crosses into the handler |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-04 | Elevation of Privilege | POST /classify handler | mitigate | `requirePermission('phishing','analyze')` early-return before any work; `analyze` granted only to admin/super-admin (V4 access control, server-side not just middleware cookie check) |
| T-19-05 | Tampering | `id` path param | mitigate | `UUID_RE` shape validation returns 400 before any query, preventing a malformed id from surfacing as an uncaught Postgres 500 (V5 input validation) |
| T-19-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed this phase; Package Legitimacy Gate N/A per 19-RESEARCH.md |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` — clean (route-handler tests are not this repo's convention; vitest.config does not scan app/**)
- Manual curl auth matrix (401 / 403 / 400 / 404 / 200) per 19-VALIDATION.md Manual-Only Verifications
- Route follows the ACCESS-01 carry-forward convention established in Phase 18
</verification>

<success_criteria>
- POST /api/phishing/campaigns/{id}/classify enforces requirePermission('phishing','analyze'), validates UUID, delegates to classifyCampaign, returns the verdict payload; rejects unauthenticated/unauthorized/malformed requests (CLASSIFY-05)
</success_criteria>

<output>
Create `.planning/phases/19-classification-engine/19-02-SUMMARY.md` when done
</output>
