From 3e8d5b83c9e4fdb9b63d0810454d272f61e7f63d Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 08:26:20 -0400 Subject: [PATCH 1/2] feat(19-02): add POST /api/phishing/campaigns/[id]/classify route - requirePermission('phishing','analyze') early-return (same action as /analyze, Phase 18 D-06) - UUID_RE guard on campaign id before any DB query (T-19-05) - 404 when campaign id is well-formed but not found - delegates to classifyCampaign(id) from lib/services/campaign-classifier.ts (Plan 01), returns flat ClassifyResult payload --- .../phishing/campaigns/[id]/classify/route.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/classify/route.ts diff --git a/app/api/phishing/campaigns/[id]/classify/route.ts b/app/api/phishing/campaigns/[id]/classify/route.ts new file mode 100644 index 0000000..74f5db2 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/classify/route.ts @@ -0,0 +1,51 @@ +/** + * POST /api/phishing/campaigns/[id]/classify + * + * On-demand (re-)trigger for campaign classification (D-02). Enforces the + * same phishing/analyze permission gate as + * `/api/phishing/tickets/[ticket_id]/analyze` (Phase 18 D-06 convention — + * NOT a new permission), validates the campaign id as a UUID (V5), then + * delegates to `classifyCampaign` (Plan 01) and returns the flat camelCase + * verdict payload. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { classifyCampaign } from '@/lib/services/campaign-classifier'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requirePermission('phishing', 'analyze'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + try { + const campaignRes = await postgresClient.query<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + const result = await classifyCampaign(id); + return NextResponse.json(result); + } 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 } + ); + } +} From 85979080151137b83109fb7a80c275a70ae4ef17 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 08:27:05 -0400 Subject: [PATCH 2/2] docs(19-02): complete classify route plan summary Co-Authored-By: Claude Sonnet 5 --- .../19-classification-engine/19-02-SUMMARY.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .planning/phases/19-classification-engine/19-02-SUMMARY.md diff --git a/.planning/phases/19-classification-engine/19-02-SUMMARY.md b/.planning/phases/19-classification-engine/19-02-SUMMARY.md new file mode 100644 index 0000000..7b4b0bc --- /dev/null +++ b/.planning/phases/19-classification-engine/19-02-SUMMARY.md @@ -0,0 +1,99 @@ +--- +phase: 19-classification-engine +plan: 02 +subsystem: security +tags: [phishing-triage, route-handler, access-control, next-app-router] + +# Dependency graph +requires: + - phase: 19-classification-engine + plan: 01 + provides: "classifyCampaign(campaignId) orchestrator + ClassifyResult shape (lib/services/campaign-classifier.ts)" +provides: + - "POST /api/phishing/campaigns/{id}/classify — on-demand campaign (re-)classification endpoint" +affects: ["20 (remediation actions) — consumes recommendedActions/requiresApproval via this route"] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Phase 18 D-06 auth convention: requirePermission('phishing','analyze') early-return, same action as the sibling /analyze route — no new permission introduced" + - "UUID_RE shape-validation guard (copied verbatim from campaigns/[id]/route.ts) before any DB query" + +key-files: + created: + - "app/api/phishing/campaigns/[id]/classify/route.ts" + modified: [] + +key-decisions: + - "Reused the exact UUID_RE regex and 400/404/500 response shapes from campaigns/[id]/route.ts rather than the ticket_id-numeric guard from analyze/route.ts, since {id} here is a campaign UUID, not an Autotask ticket_id — plan explicitly called this out." + - "Existence check (SELECT id FROM campaigns WHERE id = $1) happens before calling classifyCampaign, so an unknown-but-well-formed UUID returns 404 rather than surfacing as a classifyCampaign internal error / 500." + +requirements-completed: [CLASSIFY-05] + +# Metrics +duration: 12min +completed: 2026-07-16 +--- + +# Phase 19 Plan 02: POST /api/phishing/campaigns/[id]/classify Route Summary + +**On-demand campaign classification trigger: `requirePermission('phishing','analyze')` auth gate (same action as the existing `/analyze` route, Phase 18 D-06), UUID_RE shape validation before any query, 404 for unknown campaigns, delegates to `classifyCampaign` and returns its flat camelCase verdict payload unmodified.** + +## Performance + +- **Duration:** ~12 min +- **Completed:** 2026-07-16T12:26:26Z +- **Tasks:** 1 completed +- **Files modified:** 1 (new) + +## Accomplishments + +- Added `app/api/phishing/campaigns/[id]/classify/route.ts` exporting `POST`, following the exact analog mapping specified in `19-PATTERNS.md`: + - Auth: `requirePermission('phishing', 'analyze')` early-return (verbatim from `tickets/[ticket_id]/analyze/route.ts`) — reuses the `analyze` action already granted to admin/super-admin only (Phase 18 D-06); no new permission added. + - Validation: `UUID_RE` guard (verbatim from `campaigns/[id]/route.ts`) returns 400 before any DB access if `id` isn't a well-formed UUID (V5/T-19-05). + - Existence check: `SELECT id FROM campaigns WHERE id = $1` — 404 if no row (mirrors the campaign-detail route's 404 convention). + - Delegates to `classifyCampaign(id)` (Plan 01's orchestrator) and returns the `ClassifyResult` directly — already flat camelCase, no re-mapping needed. + - `try/catch` wraps the DB check + classify call; on error logs `[PHISHING-CLASSIFY] Failed to classify campaign` and returns 500 with `{ error, message }`. + +## Task Commits + +1. **Task 1: POST /api/phishing/campaigns/[id]/classify route handler** + - `3e8d5b8` (feat) — added route handler with auth gate, UUID guard, 404, classifyCampaign delegation + +**Plan metadata:** this SUMMARY.md commit (see below) + +## Files Created/Modified + +- `app/api/phishing/campaigns/[id]/classify/route.ts` — new route handler, 51 lines + +## Decisions Made + +- Used the campaign-detail route's `UUID_RE` (not the ticket_id numeric guard from `/analyze`), since this route's path param is a campaign UUID — matches the plan's explicit instruction. +- Checked campaign existence with a lightweight `SELECT id` query before invoking `classifyCampaign`, so a syntactically valid but nonexistent UUID cleanly returns 404 instead of letting `classifyCampaign`'s internal `gatherCampaignEvidence` silently produce a zero-evidence classification for a campaign that was never real. + +## Deviations from Plan + +None — plan executed exactly as written. One doc-comment wording tweak: the file's header comment originally quoted the literal string `requirePermission('phishing', 'analyze')`, which caused the acceptance criterion's `grep -c ... === 1` check to count 2 matches (the comment + the code line). Reworded the comment to describe the gate without repeating the exact call-site string, so the grep count matches the plan's stated acceptance criterion exactly (1). This is a Rule 3 (blocking-issue) auto-fix — no behavior change, comment-only. + +## Issues Encountered + +- **Manual curl auth matrix (human-check) not run from this worktree.** This isolated worktree has no `.env`/`.env.local` file and thus no Postgres credentials or `BETTER_AUTH_SECRET`/session cookies — there is no way to start a dev server here that can authenticate as admin/super-admin/user roles or reach the real `campaigns` table. The running `pulse-app` Docker container on port 3100 is a standalone build with no live code mount, so it does not reflect this route either. **This step still needs to happen post-merge**: with a real dev server (`npm run dev`) and valid session cookies, run the 5-case matrix from the plan's `` block (200 authed-admin / 401 no-session / 403 user-role / 400 malformed-id / 404 unknown-campaign). + +## User Setup Required + +- **Manual verification (see Issues Encountered above):** after this plan merges to a branch/environment with real Postgres + Better Auth credentials, run the curl auth matrix described in `19-02-PLAN.md`'s `` block against a real campaign UUID before considering CLASSIFY-05 fully verified end-to-end. + +## Next Phase Readiness + +- `POST /api/phishing/campaigns/{id}/classify` is wired end-to-end: auth → UUID validation → existence check → `classifyCampaign` → flat verdict response. Phase 20 (remediation actions) can call this route (or `classifyCampaign` directly) and consume `recommendedActions`/`requiresApproval` from the response. +- The Plan 01 ASSUMPTION FLAG (click-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule`) still needs a quick user confirmation before Phase 20 — this route does not change or resolve that; it surfaces whatever `classifyCampaign` currently returns. + +--- +*Phase: 19-classification-engine* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +- FOUND: app/api/phishing/campaigns/[id]/classify/route.ts +- FOUND commit: 3e8d5b8 (feat: POST /api/phishing/campaigns/[id]/classify route handler)