docs(22): create phase plan (6 plans, 3 waves)
This commit is contained in:
parent
9164dc6177
commit
b4707ce962
8 changed files with 1024 additions and 17 deletions
|
|
@ -503,7 +503,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
|
|||
| 19. Classification Engine | v3.0 | 2/2 | Complete | 2026-07-16 |
|
||||
| 20. Remediation, Approval & Audit Safety | v3.0 | 2/2 | Complete | 2026-07-16 |
|
||||
| 21. Autotask Triage Note | v3.0 | 2/2 | Complete | 2026-07-16 |
|
||||
| 22. Approval UI (LiveLink) | v3.0 | 0/TBD | Not started | - |
|
||||
| 22. Approval UI (LiveLink) | v3.0 | 0/6 | Not started | - |
|
||||
|
||||
### Phase 22: Approval UI (LiveLink)
|
||||
**Goal**: A security operator opens an Autotask ticket, clicks a LiveLink button, and lands on a Pulse page scoped to that ticket showing the campaign's timeline, evidence, and classification — with approve/remediate/mark-false-positive actions right there, so no one is calling the Phase 20 APIs by hand.
|
||||
|
|
@ -516,7 +516,13 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
|
|||
4. The page shows the current classification (SPAM/UNWANTED/THREAT), confidence, reasons, and recommended remediation action(s) from Phase 19
|
||||
5. Approve, remediate, and mark-false-positive buttons call the Phase 20 APIs directly from the page and reflect the resulting state (e.g. a remediated campaign shows as remediated, not re-offered for approval)
|
||||
6. An operator without the elevated permission REMED-02/ACCESS-01 already require sees the approve/remediate actions disabled or hidden rather than a failed request; the page never uses a relaxed or separate permission check from the underlying APIs
|
||||
**Plans**: TBD
|
||||
**Plans**: 6 plans
|
||||
- [ ] 22-01-PLAN.md — Pure testable logic: ticket->campaign resolver, 7-action default-params, timeline merge (REVIEW-01, REVIEW-02, REVIEW-04)
|
||||
- [ ] 22-02-PLAN.md — Backend routes: new ticket->campaign resolver + extend campaign-detail (evidence/timeline/classification/blast radius) + list firstReportTicketId (REVIEW-01..04)
|
||||
- [ ] 22-03-PLAN.md — Evidence display: shadcn tooltip + inert UrlList (D-09) + tabbed EvidenceCard (REVIEW-03)
|
||||
- [ ] 22-04-PLAN.md — ClassificationCard + TimelineCard (REVIEW-02, REVIEW-04)
|
||||
- [ ] 22-05-PLAN.md — ActionAreaCard: approve/remediate/mark-false-positive with server-identical permission gating (REVIEW-05, REVIEW-06)
|
||||
- [ ] 22-06-PLAN.md — Review page + campaigns list page + nav entry (REVIEW-01, REVIEW-05, REVIEW-06)
|
||||
**UI hint**: yes
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- lib/services/phishing-ticket-resolver.ts
|
||||
- lib/services/phishing-ticket-resolver.test.ts
|
||||
- lib/services/remediation-default-params.ts
|
||||
- lib/services/remediation-default-params.test.ts
|
||||
- lib/services/phishing-timeline.ts
|
||||
- lib/services/phishing-timeline.test.ts
|
||||
autonomous: true
|
||||
requirements: [REVIEW-01, REVIEW-02, REVIEW-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Given a ticket id with no reports row, the resolver returns { found: false }"
|
||||
- "Given a ticket id whose report has a null campaign_id, the resolver returns { found: true, campaignId: null }"
|
||||
- "Given a ticket id whose report is grouped, the resolver returns { found: true, reportId, campaignId, ticketNumber }"
|
||||
- "deriveDefaultParams returns the exact param object for each of the 7 action types"
|
||||
- "mergeTimeline returns a single array of report/classification/audit entries sorted ascending by timestamp"
|
||||
artifacts:
|
||||
- path: "lib/services/phishing-ticket-resolver.ts"
|
||||
provides: "resolveTicketToCampaign(ticketId) DB lookup"
|
||||
exports: ["resolveTicketToCampaign", "TicketCampaignResolution"]
|
||||
- path: "lib/services/remediation-default-params.ts"
|
||||
provides: "7-action-type default-param derivation"
|
||||
exports: ["deriveDefaultParams", "DefaultParamEvidence"]
|
||||
- path: "lib/services/phishing-timeline.ts"
|
||||
provides: "chronological merge of reports + classifications + audit events"
|
||||
exports: ["mergeTimeline", "TimelineEntry"]
|
||||
key_links:
|
||||
- from: "lib/services/phishing-ticket-resolver.ts"
|
||||
to: "reports table"
|
||||
via: "postgresClient.query SELECT ... FROM reports WHERE ticket_id = $1"
|
||||
pattern: "FROM reports WHERE ticket_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extract the three pieces of genuinely new pure logic in this phase into testable `lib/services/*.ts` files so they can be unit-tested under vitest (whose `test.include` is `lib/**/*.test.ts` only — `app/**` and `components/**` have no coverage). These are the phase's Wave 0 foundation: the ticket→campaign resolver (REVIEW-01), the 7-action-type default-param derivation (REVIEW-04), and the timeline merge/sort (REVIEW-02, server-merge approach chosen per RESEARCH.md Alternatives Considered).
|
||||
|
||||
Purpose: Downstream route (plan 02) and component (plans 04/05) work consumes these; extracting them makes the phase's only new business logic verifiable rather than trapped inline in untestable route/component files.
|
||||
Output: 3 service modules + 3 vitest test files, all green.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-RESEARCH.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
|
||||
|
||||
<interfaces>
|
||||
From lib/services/postgres-client.ts:
|
||||
default export `postgresClient` (also named export). Use `postgresClient.query<Row>(sql, params)`.
|
||||
|
||||
From lib/services/remediation-service.ts (the shape approve params must match):
|
||||
export interface ApproveActionInput { actionType: string; params?: Record<string, unknown>; }
|
||||
|
||||
Confirmed 7-action vocabulary (lib/services/campaign-classifier.ts mapVerdictToActions):
|
||||
no_action | warn_user | block_sender | purge_message | reset_password | isolate_endpoint | disable_forwarding_rule
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: phishing-ticket-resolver.ts + test</name>
|
||||
<files>lib/services/phishing-ticket-resolver.ts, lib/services/phishing-ticket-resolver.test.ts</files>
|
||||
<read_first>
|
||||
- lib/services/campaign-classifier.ts (top-level exported-async-function + typed-row + postgresClient idiom, and an existing vitest mock of postgres-client)
|
||||
- lib/services/campaign-classifier.test.ts (how postgres-client is mocked with vi.mock)
|
||||
- app/api/phishing/tickets/[ticket_id]/analyze/route.ts (the Number(ticket_id) param semantics — ticket_id is the numeric tickets.id / reports.ticket_id)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/phishing-ticket-resolver.ts")
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given reports has no row for ticketId: resolveTicketToCampaign(ticketId) resolves to { found: false }
|
||||
- Given a reports row with campaign_id = null: resolves to { found: true, reportId, campaignId: null, ticketNumber }
|
||||
- Given a reports row with a campaign_id UUID: resolves to { found: true, reportId, campaignId: <uuid>, ticketNumber }
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/phishing-ticket-resolver.ts` exporting `interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; }` and `export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution>`. It runs `postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>('SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1', [ticketId])`, reads `res.rows[0]`; if absent returns `{ found: false }`; otherwise returns `{ found: true, reportId: row.id, campaignId: row.campaign_id, ticketNumber: row.ticket_number }`. Import postgresClient matching the sibling-file convention (default import from `./postgres-client`). Do NOT put requirePermission or param parsing here — that lives in the route (plan 02). Write `phishing-ticket-resolver.test.ts` that mocks `./postgres-client` via `vi.mock` (mirror campaign-classifier.test.ts) and asserts the three behaviors above by controlling the mocked query's returned rows.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/phishing-ticket-resolver.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/phishing-ticket-resolver.test.ts` passes with at least 3 assertions (no-report, ungrouped, grouped)
|
||||
- `grep -q "FROM reports WHERE ticket_id = \$1" lib/services/phishing-ticket-resolver.ts` succeeds
|
||||
- The file contains no `requirePermission` and no `NextResponse` import (pure service, route-free)
|
||||
</acceptance_criteria>
|
||||
<done>resolveTicketToCampaign exported, test green, three resolution states covered.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: remediation-default-params.ts + test</name>
|
||||
<files>lib/services/remediation-default-params.ts, lib/services/remediation-default-params.test.ts</files>
|
||||
<read_first>
|
||||
- lib/services/campaign-classifier.ts (mapVerdictToActions — the exhaustive-switch shape to mirror)
|
||||
- lib/services/remediation-service.ts lines 42-45 (ApproveActionInput shape the params feed into)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Action Area Spec" → the 7-row default-params table)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "lib/services/remediation-default-params.ts")
|
||||
</read_first>
|
||||
<behavior>
|
||||
- deriveDefaultParams('no_action', ev) === {}
|
||||
- deriveDefaultParams('warn_user', ev) === { recipientEmail: ev.requesterEmail ?? '', message: '' }
|
||||
- deriveDefaultParams('block_sender', ev) === { senderEmail: ev.senderEmail ?? '', senderDomain: ev.senderDomain ?? '' }
|
||||
- deriveDefaultParams('purge_message', ev) === { messageId: ev.messageId ?? '', mailboxes: [] }
|
||||
- deriveDefaultParams('reset_password', ev) === { userPrincipalName: ev.requesterEmail ?? '' }
|
||||
- deriveDefaultParams('isolate_endpoint', ev) === { deviceId: '' }
|
||||
- deriveDefaultParams('disable_forwarding_rule', ev) === { userPrincipalName: ev.requesterEmail ?? '', ruleName: '' }
|
||||
- deriveDefaultParams('unknown_future_type', ev) === {}
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/remediation-default-params.ts` exporting `interface DefaultParamEvidence { requesterEmail: string | null; senderEmail: string | null; senderDomain: string | null; messageId: string | null; }` and `export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record<string, unknown>` implemented as a switch over the 7 action types exactly per the UI-SPEC Action Area default-params table (values listed in behavior above), with a `default: return {}` for any unknown/future action type. Pure function, no imports beyond types. Write `remediation-default-params.test.ts` asserting all 8 behaviors above (7 known types + 1 unknown fallback), using an evidence fixture with non-null values to prove pass-through and a second with all-null values to prove the `?? ''` / `?? ''` fallbacks.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/remediation-default-params.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/remediation-default-params.test.ts` passes with assertions for all 7 known action types plus the unknown-type fallback
|
||||
- `grep -c "case '" lib/services/remediation-default-params.ts` returns at least 7
|
||||
- deriveDefaultParams is a pure function (no postgresClient / fetch / NextResponse import)
|
||||
</acceptance_criteria>
|
||||
<done>All 7 action-type default-param shapes + unknown fallback verified by test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: phishing-timeline.ts + test (server-merge)</name>
|
||||
<files>lib/services/phishing-timeline.ts, lib/services/phishing-timeline.test.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (section "Timeline Spec" — the three sources and ascending-by-created_at ordering)
|
||||
- app/api/phishing/campaigns/[id]/route.ts (the camelCase report/classification row shapes this merge consumes)
|
||||
- lib/services/phishing-audit.ts (the 4 canonical audit_events event_type values)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given 2 reports, 1 classification, 2 audit events with interleaved timestamps, mergeTimeline returns a 5-element array sorted ascending by `at`
|
||||
- Each entry carries a discriminant `kind` of 'report' | 'classification' | 'audit' and the source fields (report: reportId/ticketNumber/companyName; classification: verdict/confidence; audit: eventType/actor/payload)
|
||||
- Ties on identical timestamps preserve a stable order (report, then classification, then audit) and do not throw
|
||||
</behavior>
|
||||
<action>
|
||||
Create `lib/services/phishing-timeline.ts` exporting a discriminated union `type TimelineEntry` with three variants: `{ kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }`, `{ kind: 'classification'; at: string; verdict: string; confidence: string | null }`, `{ kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }`. Export `function mergeTimeline(reports: {...}[], classifications: {...}[], auditEvents: {...}[]): TimelineEntry[]` that maps each source into its variant (using each source's created_at as `at`), concatenates, and sorts ascending by `at` (compare via `Date(a.at).getTime() - Date(b.at).getTime()`; on equal times fall back to a fixed kind priority report<classification<audit). Pure function, no DB/fetch imports. Write `phishing-timeline.test.ts` asserting the three behaviors above with an interleaved-timestamp fixture.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/phishing-timeline.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx vitest run lib/services/phishing-timeline.test.ts` passes, including an assertion that output timestamps are non-decreasing
|
||||
- Output length equals sum of the three input array lengths
|
||||
- `grep -q "kind: 'audit'" lib/services/phishing-timeline.ts` succeeds (all three variants present)
|
||||
- mergeTimeline imports no postgresClient / NextResponse (pure)
|
||||
</acceptance_criteria>
|
||||
<done>mergeTimeline produces a single ascending chronological array across all three sources; test green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| route→service (plan 02 calls these) | These services receive an already-parsed `ticketId: number` and already-fetched row arrays; they perform no input parsing themselves |
|
||||
| service→DB | resolveTicketToCampaign issues a parameterized query only |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-01 | Tampering (SQL injection) | phishing-ticket-resolver.ts | mitigate | Parameterized query `WHERE ticket_id = $1` with a `number` param — no string interpolation into SQL |
|
||||
| T-22-02 | Information Disclosure | resolveTicketToCampaign | accept | Returns only reportId/campaignId/ticketNumber; access control is enforced in the route layer (plan 02), not here — this pure service is never exposed directly |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan; per 22-RESEARCH Package Legitimacy Audit the phase introduces zero new npm packages |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test` (full suite) green
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Three pure-logic services exist with passing unit tests covering resolver (3 states), default-params (7 types + fallback), and timeline merge (chronological, 3 sources).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-01-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["22-01"]
|
||||
files_modified:
|
||||
- app/api/phishing/tickets/[ticket_id]/campaign/route.ts
|
||||
- app/api/phishing/campaigns/[id]/route.ts
|
||||
- app/api/phishing/campaigns/route.ts
|
||||
autonomous: true
|
||||
requirements: [REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/phishing/tickets/{ticketId}/campaign returns { found:false } (200) when no report exists, { found:true, campaignId:null } for an ungrouped report, and { found:true, campaignId } for a grouped report"
|
||||
- "GET /api/phishing/campaigns/{id} additionally returns remediationActions (with derived completedAt), auditEvents, full classification fields (reasons/recommendedActions/requiresApproval), full message evidence (headers/urls/attachments/bodyPreview), a fresh blastRadius, and a merged chronological timeline"
|
||||
- "GET /api/phishing/campaigns additionally returns firstReportTicketId per campaign"
|
||||
- "All three routes reject an unauthenticated/unauthorized caller via requirePermission('phishing','read')"
|
||||
artifacts:
|
||||
- path: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts"
|
||||
provides: "ticket→campaign resolver route"
|
||||
exports: ["GET"]
|
||||
- path: "app/api/phishing/campaigns/[id]/route.ts"
|
||||
provides: "extended campaign-detail response for the review page"
|
||||
contains: "blastRadius"
|
||||
- path: "app/api/phishing/campaigns/route.ts"
|
||||
provides: "campaigns list with firstReportTicketId for row navigation"
|
||||
contains: "firstReportTicketId"
|
||||
key_links:
|
||||
- from: "app/api/phishing/tickets/[ticket_id]/campaign/route.ts"
|
||||
to: "lib/services/phishing-ticket-resolver.ts"
|
||||
via: "resolveTicketToCampaign(ticketId)"
|
||||
pattern: "resolveTicketToCampaign"
|
||||
- from: "app/api/phishing/campaigns/[id]/route.ts"
|
||||
to: "lib/services/mimecast-blast-radius.ts"
|
||||
via: "getBlastRadius fresh per request"
|
||||
pattern: "getBlastRadius"
|
||||
- from: "app/api/phishing/campaigns/[id]/route.ts"
|
||||
to: "lib/services/phishing-timeline.ts"
|
||||
via: "mergeTimeline(reports, classifications, auditEvents)"
|
||||
pattern: "mergeTimeline"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Provide the review page's read surface. Add one new thin resolver route (ticket ID → campaign) and additively extend the two existing campaign endpoints so the page has everything REVIEW-02/03/04 need — without breaking any existing consumer (grep confirms the detail route has no other consumer besides itself).
|
||||
|
||||
Purpose: The Phase 20 write routes (approve/remediate/mark-false-positive) already exist and are reused verbatim; the only missing backend piece is the enriched read data. This plan supplies it.
|
||||
Output: 1 new route + 2 additively-extended routes.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-RESEARCH.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
From lib/services/phishing-ticket-resolver.ts (plan 01):
|
||||
export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution>
|
||||
export interface TicketCampaignResolution { found: boolean; reportId?: string; campaignId?: string | null; ticketNumber?: string | null; }
|
||||
|
||||
From lib/services/phishing-timeline.ts (plan 01):
|
||||
export function mergeTimeline(reports, classifications, auditEvents): TimelineEntry[]
|
||||
|
||||
From lib/services/mimecast-blast-radius.ts (existing):
|
||||
export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult>
|
||||
BlastRadiusInput { messageId?: string; sender: string; recipient: string; subject: string; dateWindow: { start: Date; end: Date }; }
|
||||
BlastRadiusResult = { status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? } | { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: {recipient; status}[] }
|
||||
|
||||
From lib/services/remediation-service.ts (existing, for reference — NOT modified here):
|
||||
ApproveActionInput { actionType: string; params?: Record<string, unknown> }
|
||||
|
||||
Existing detail route messages query TODAY only selects id/report_id/message_id/subject — this plan widens it to include headers, urls, attachments, body_preview.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: NEW ticket→campaign resolver route</name>
|
||||
<files>app/api/phishing/tickets/[ticket_id]/campaign/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/phishing/tickets/[ticket_id]/analyze/route.ts (exact requirePermission + Number(ticket_id) validation + try/catch idiom to mirror)
|
||||
- lib/services/phishing-ticket-resolver.ts (plan 01 — the function this route wraps)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for this route)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` exporting `GET(request, { params }: { params: Promise<{ ticket_id: string }> })`. First `const { error } = await requirePermission('phishing', 'read'); if (error) return error;`. Then `const { ticket_id } = await params; const ticketId = Number(ticket_id); if (!Number.isFinite(ticketId)) return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 });`. Wrap in try/catch (copy the `console.error('[PHISHING-TICKET-CAMPAIGN] ...', ticketId, err)` + 500 shape from analyze/route.ts). In the try, call `resolveTicketToCampaign(ticketId)` and return it directly via `NextResponse.json(...)`. Note deliberate design (D-07): a missing report is `{ found: false }` at HTTP 200, NOT a 404 — the page distinguishes "valid ticket, not triaged yet" from a hard error via the `found` boolean. Use GET (pure read), not POST. Import postgresClient is NOT needed here (the service owns the query).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -c "phishing/tickets/\[ticket_id\]/campaign" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "requirePermission('phishing', 'read')" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
|
||||
- `grep -q "resolveTicketToCampaign" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
|
||||
- `grep -q "Number.isFinite" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds
|
||||
- `grep -q "export async function GET" app/api/phishing/tickets/[ticket_id]/campaign/route.ts` succeeds and no POST/PUT/PATCH/DELETE export exists
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>Resolver route returns found/reportId/campaignId/ticketNumber, auth-gated, param-validated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: EXTEND campaigns/[id] detail route (evidence + timeline + classification + blast radius)</name>
|
||||
<files>app/api/phishing/campaigns/[id]/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/phishing/campaigns/[id]/route.ts (the full existing file — extend, do not rewrite; keep every existing field/query untouched)
|
||||
- lib/services/campaign-classifier.ts lines 320-360 (gatherCampaignEvidence — the CORRECT sender/recipient/subject/dateWindow derivation to copy for getBlastRadius; do NOT copy triage-note-service.ts's empty-string call — Pitfall 3)
|
||||
- lib/services/phishing-eml-service.ts (the exact messages.headers/urls/attachments/body_preview persisted JSONB shapes)
|
||||
- lib/services/phishing-audit.ts (audit_events columns: actor, event_type, payload; the remediation_completed payload.actionId used to derive completedAt)
|
||||
- lib/services/phishing-timeline.ts (plan 01 — mergeTimeline)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "app/api/phishing/campaigns/[id]/route.ts (EXTEND)")
|
||||
</read_first>
|
||||
<action>
|
||||
Additively extend the existing GET handler. (1) Widen the existing `messagesRes` SELECT to also return `headers, urls, attachments, body_preview` (all real columns on `messages`; subject already comes from `headers->>'subject'`) and include them (camelCased: headers/urls/attachments/bodyPreview) in the existing `messages` map output. (2) Add a `remediationRes` query `SELECT id::text, action_type, status, params, approved_by, approved_at::text FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`. (3) Add an `auditRes` query `SELECT id::text, actor, event_type, payload, created_at::text FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`. (4) Widen the existing `classificationsRes` SELECT to add `reasons, recommended_actions, requires_approval` (real columns; keep `ORDER BY created_at DESC`) and include them (camelCased reasons/recommendedActions/requiresApproval) in the map. (5) Build `completedAtByActionId` in app code by iterating auditRes rows where `event_type === 'remediation_completed'` and reading `(payload as {actionId?:string}).actionId → created_at`; map `remediationActions` to camelCase including `completedAt: completedAtByActionId.get(a.id) ?? null` (there is NO completed_at column — Pitfall 2). (6) Derive `blastRadius` by copying campaign-classifier.ts's gatherCampaignEvidence logic exactly: take the earliest report (reports[0]), find its message, find the `sender`-type indicator, call `getBlastRadius({ sender: senderIndicator?.value ?? primaryMessage?.headers.from.email ?? '', recipient: primaryReport.requesterEmail ?? '', subject: primaryMessage?.subject ?? primaryReport.title ?? '', dateWindow: { start: createdAt - 24h, end: createdAt + 24h } })`; when there is no report use `{ status: 'unavailable', reason: 'not_configured' }`. Await it inside the existing try block. (7) Add `timeline: mergeTimeline(reports, classifications, auditEvents)` to the response using the already-mapped camelCase arrays. Add the four new response fields (`remediationActions`, `auditEvents`, `blastRadius`, `timeline`) and the widened `messages`/`classifications` shapes to the returned JSON. Do NOT alter the existing catch block, the UUID_RE guard, or any existing field name. Add imports for `getBlastRadius`/`BlastRadiusResult` from `@/lib/services/mimecast-blast-radius` and `mergeTimeline` from `@/lib/services/phishing-timeline`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/\[id\]/route" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "getBlastRadius" app/api/phishing/campaigns/[id]/route.ts` succeeds
|
||||
- `grep -q "mergeTimeline" app/api/phishing/campaigns/[id]/route.ts` succeeds
|
||||
- `grep -q "FROM remediation_actions WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds
|
||||
- `grep -q "FROM audit_events WHERE campaign_id" app/api/phishing/campaigns/[id]/route.ts` succeeds
|
||||
- `grep -q "recommended_actions" app/api/phishing/campaigns/[id]/route.ts` and `grep -q "requires_approval" ...` both succeed
|
||||
- `grep -v '^#' app/api/phishing/campaigns/[id]/route.ts | grep -c "completed_at"` returns 0 (no reference to the nonexistent column)
|
||||
- The existing `requirePermission('phishing', 'read')` call and `UUID_RE` guard remain present
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>Detail route returns enriched evidence, timeline, classification, remediation actions with derived completedAt, and fresh blast radius — all additive.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: EXTEND campaigns list route with firstReportTicketId</name>
|
||||
<files>app/api/phishing/campaigns/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/phishing/campaigns/route.ts (full existing file — additive change only)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page section — firstReportTicketId is the row-click navigation target)
|
||||
</read_first>
|
||||
<action>
|
||||
Add one correlated subquery column to the existing campaigns SELECT: `(SELECT r.ticket_id::text FROM reports r WHERE r.campaign_id = c.id ORDER BY r.created_at ASC LIMIT 1) AS first_report_ticket_id`. Alias the campaigns table as `c` in that SELECT (currently unaliased) — apply the alias consistently to the existing selected columns and to the `${statusFilter}`/ORDER BY so nothing breaks. Add `firstReportTicketId: c.first_report_ticket_id` to the existing `items.map()`. Do NOT change the count query, the limit/offset logic, the requirePermission gate, or the response envelope shape (`{ items, total, limit, offset }`). Purely additive field.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -c "campaigns/route" | grep -qx 0 && echo TYPECHECK-CLEAN</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "first_report_ticket_id" app/api/phishing/campaigns/route.ts` succeeds
|
||||
- `grep -q "firstReportTicketId" app/api/phishing/campaigns/route.ts` succeeds
|
||||
- The response still returns `{ items, total, limit, offset }` (envelope unchanged)
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>List route returns firstReportTicketId per campaign for row-click navigation.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser→API | Operator supplies ticket_id (numeric) and campaign id (UUID) in the URL path — untrusted |
|
||||
| API→DB | Parameterized queries only |
|
||||
| API→Mimecast (server-side) | getBlastRadius runs server-side with server-only Mimecast creds; never called from the client |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-03 | Elevation of Privilege | all three routes | mitigate | Each route calls `requirePermission('phishing', 'read')` and returns its error before any query — identical to the 6 existing phishing routes |
|
||||
| T-22-04 | Tampering (SQLi) | resolver + extended routes | mitigate | ticket_id parsed via `Number.isFinite`; campaign id gated by existing `UUID_RE`; all queries parameterized |
|
||||
| T-22-05 | Information Disclosure (campaign-UUID enumeration) | campaigns/[id] | mitigate | requirePermission('phishing','read') gates the whole route before the DB query; a guessed UUID without read permission 401/403s first |
|
||||
| T-22-06 | Information Disclosure (blast radius scoped to wrong sender/recipient) | campaigns/[id] blastRadius | mitigate | Copy campaign-classifier's real sender/recipient derivation, not triage-note-service's empty-string call (Pitfall 3) — prevents an unscoped Mimecast fan-out |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH Package Legitimacy Audit — zero new packages) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test` green (plan 01 tests still pass; no test coverage for routes under vitest config — type-check is the safety net per CLAUDE.md)
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
- Manual: `curl` the resolver route for a known-triaged ticket returns `found:true` with a campaignId
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Review page can fetch (a) a ticket→campaign resolution and (b) an enriched campaign detail containing evidence, timeline, classification, remediation actions, and blast radius; list page can fetch campaigns with firstReportTicketId. All auth-gated, all additive.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- components/ui/tooltip.tsx
|
||||
- components/phishing/url-list.tsx
|
||||
- components/phishing/evidence-card.tsx
|
||||
autonomous: true
|
||||
requirements: [REVIEW-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link"
|
||||
- "The evidence card shows parsed Headers, URLs, Attachments, Body preview, and Blast Radius tabs for a selected message"
|
||||
- "Body preview renders as plain text in a <pre>, never via dangerouslySetInnerHTML"
|
||||
- "Blast radius renders an explicit 'unavailable' state when status is unavailable, and counts + per-recipient table when ok"
|
||||
artifacts:
|
||||
- path: "components/ui/tooltip.tsx"
|
||||
provides: "shadcn tooltip primitive (for later disabled-button explanations)"
|
||||
- path: "components/phishing/url-list.tsx"
|
||||
provides: "inert copy-only URL list (D-09)"
|
||||
exports: ["UrlList"]
|
||||
- path: "components/phishing/evidence-card.tsx"
|
||||
provides: "tabbed EML evidence display (REVIEW-03)"
|
||||
exports: ["EvidenceCard"]
|
||||
key_links:
|
||||
- from: "components/phishing/url-list.tsx"
|
||||
to: "navigator.clipboard"
|
||||
via: "writeText on copy button click"
|
||||
pattern: "navigator.clipboard.writeText"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the read-only evidence surface for the review page: the shadcn `tooltip` primitive (needed later by the action card), the inert copy-only `UrlList` component enforcing D-09's stricter-than-sanitization posture, and the tabbed `EvidenceCard` rendering parsed EML headers, URLs, attachments, body preview, and Mimecast blast radius (REVIEW-03).
|
||||
|
||||
Purpose: This is the phase's biggest XSS-exposure surface (attacker-controlled email content). It is built as a pure presentational component so the review page (plan 06) composes it without embedding rendering logic.
|
||||
Output: 1 shadcn primitive + 2 phishing components.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
messages evidence shape (from the extended detail route, plan 02):
|
||||
headers: { from:{displayName,email,domain}; replyTo; returnPath; to:string[]; cc:string[]; subject; date; messageId; receivedChain:string[]; authResults:{spf?;dkim?;dmarc?}; authResultsOriginal:{...}|null }
|
||||
urls: string[]
|
||||
attachments: Array<{ filename:string|null; contentType:string|null; size:number; checksum:string|null; related:boolean }>
|
||||
bodyPreview: string (plain text, ≤500 chars)
|
||||
|
||||
blastRadius (from detail route):
|
||||
{ status:'unavailable'; reason:'not_configured'|'lookup_failed'; error? }
|
||||
| { status:'ok'; matched; delivered; held; rejected; clicked; perRecipient: Array<{recipient; status:'delivered'|'held'|'rejected'|'unknown'}> }
|
||||
|
||||
Reused primitives (do not modify): components/ui/{tabs,table,accordion,alert,card,select,status-badge}.tsx, sonner toast, lucide-react (Copy).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add tooltip primitive + build UrlList (D-09 inert)</name>
|
||||
<files>components/ui/tooltip.tsx, components/phishing/url-list.tsx</files>
|
||||
<read_first>
|
||||
- components/ui/status-badge.tsx (existing primitive prop shape + cn() styling convention)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §2 — exact url-list markup + copy affordance)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section "components/phishing/url-list.tsx")
|
||||
</read_first>
|
||||
<action>
|
||||
First run `npx shadcn add tooltip` (official registry, generates `components/ui/tooltip.tsx` — no npm dependency added; if the CLI prompts, accept defaults). Then create `components/phishing/url-list.tsx` as a `'use client'` component exporting `UrlList({ urls }: { urls: string[] })`. Render each URL as `<code className="font-mono text-xs truncate">{url}</code>` followed by an icon-only copy button (`Button size="icon" variant="ghost"`, lucide `Copy` at `h-3.5 w-3.5`, `aria-label="Copy URL"`) whose onClick calls `navigator.clipboard.writeText(url)` then `toast.success('Copied')`. CRITICAL (D-09): never wrap a URL in `<a>`/`href`, never attach a navigating onClick, never use `<Link>` — copy-to-clipboard only. When `urls.length === 0`, render inline text "No URLs found in this message." (size sm, muted), NOT the full EmptyState component.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f components/ui/tooltip.tsx && grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx && grep -Lq "href" components/phishing/url-list.tsx && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `components/ui/tooltip.tsx` exists and exports Tooltip/TooltipTrigger/TooltipContent/TooltipProvider
|
||||
- `grep -q "navigator.clipboard.writeText" components/phishing/url-list.tsx` succeeds
|
||||
- `grep -c "href" components/phishing/url-list.tsx` returns 0 (no href anywhere)
|
||||
- `grep -q "aria-label=\"Copy URL\"" components/phishing/url-list.tsx` succeeds
|
||||
- No new dependency added to package.json (git diff of package.json is empty)
|
||||
</acceptance_criteria>
|
||||
<done>Tooltip primitive present; UrlList renders inert copy-only URLs with zero clickable link surface.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: EvidenceCard — tabbed EML evidence (REVIEW-03)</name>
|
||||
<files>components/phishing/evidence-card.tsx</files>
|
||||
<read_first>
|
||||
- components/admin/DetailModal.tsx (card + tabs formatted/raw precedent)
|
||||
- components/ui/status-badge.tsx (StatusBadge prop shapes for SPF/DKIM/DMARC + blast-radius status badges)
|
||||
- components/phishing/url-list.tsx (task 1 — consumed here)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Evidence Display Spec §1-5, Color table for badge recipes)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md (section for classification/evidence/timeline components)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/phishing/evidence-card.tsx` (`'use client'`) exporting `EvidenceCard({ messages, blastRadius })` where `messages` is the extended detail route's message array (each with headers/urls/attachments/bodyPreview + ticketNumber-bearing report linkage) and `blastRadius` is the BlastRadiusResult. Wrap in a shadcn `<Card>` with `<CardHeader><CardTitle className="font-bold">` (explicit font-bold override — CardTitle defaults to font-semibold) and a leading lucide `FileText` icon (`h-4 w-4 mr-2 inline`). If more than one message exists, render an outer `<Select>` ("Report: Ticket #{ticketNumber} — {relative date}") choosing which message populates the tabs; default = most recently linked. Tabs via `components/ui/tabs.tsx`: (1) Headers — 2-col definition-list grid (`grid grid-cols-2 gap-x-6 gap-y-2 text-sm`) of From/Display name/Sender domain/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received chain (Received chain collapsed under an Accordion item) plus SPF/DKIM/DMARC as StatusBadge (pass=`bg-green-500/15 text-green-600`, fail=`bg-red-500/15 text-red-600`, none/neutral=`bg-slate-500/15 text-slate-600`); Message-ID/Return-Path/email addresses render `font-mono text-xs`. (2) URLs — `<UrlList urls={message.urls} />`. (3) Attachments — shadcn `Table` of filename, content-type (slate StatusBadge), human-readable size, hash (`font-mono text-xs` truncated with `title` full value + icon-only copy button `aria-label="Copy hash"`); render no file content. (4) Body preview — `<pre className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">{bodyPreview}</pre>` — NEVER dangerouslySetInnerHTML, never render as HTML. (5) Blast Radius — if `status === 'unavailable'` render the reason-specific copy from the UI-SPEC Copywriting Contract ("Blast radius unavailable — Mimecast isn't configured..." for not_configured; "Blast radius lookup failed: {error}..." for lookup_failed) with no table/CTA; if `status === 'ok'` render a labeled stat row (Matched/Delivered/Held/Rejected/Clicked) above a per-recipient `Table` (recipient `font-mono text-xs`, status StatusBadge: delivered=green, held=amber, rejected=`bg-destructive/15 text-destructive`, unknown=`bg-muted text-muted-foreground`). Every icon-only button carries an explicit aria-label.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "whitespace-pre-wrap" components/phishing/evidence-card.tsx && ! grep -q "dangerouslySetInnerHTML" components/phishing/evidence-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "evidence-card" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "dangerouslySetInnerHTML" components/phishing/evidence-card.tsx` returns 0
|
||||
- `grep -q "whitespace-pre-wrap" components/phishing/evidence-card.tsx` succeeds (body preview in a pre)
|
||||
- `grep -q "UrlList" components/phishing/evidence-card.tsx` succeeds (URLs tab delegates to the inert component)
|
||||
- `grep -q "status === 'unavailable'" components/phishing/evidence-card.tsx` (or equivalent) — explicit unavailable branch present
|
||||
- `grep -q "font-bold" components/phishing/evidence-card.tsx` succeeds (CardTitle override)
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>EvidenceCard renders all 5 tabs safely; body preview inert; blast-radius unavailable state explicit.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| attacker-controlled email content → browser DOM | headers, urls, attachment filenames/hashes, and body preview all originate from a reported (potentially malicious) email |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-07 | Tampering / Information Disclosure (stored XSS via body preview) | evidence-card.tsx | mitigate | Body preview rendered only inside `<pre className="whitespace-pre-wrap">` as JSX text; zero `dangerouslySetInnerHTML` anywhere (React auto-escapes text) |
|
||||
| T-22-08 | Tampering (clickjacking / drive-by via malicious URL click) | url-list.tsx | mitigate | D-09 — extracted URLs rendered as inert `<code>` text with copy-to-clipboard only; no `<a href>`, no navigating onClick, no `<Link>` |
|
||||
| T-22-09 | Information Disclosure (rendering raw attachment content) | evidence-card.tsx Attachments tab | mitigate | Only filename/content-type/size/hash metadata rendered; no file content is fetched or displayed (EVID-04) |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | `npx shadcn add tooltip` generates local Radix source from the official registry — not an npm package install; per 22-RESEARCH Package Legitimacy Audit the gate does not apply |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
- `npm test` still green (no component tests under vitest config)
|
||||
- Manual: render evidence card with a fixture message and confirm no clickable URLs and plain-text body
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Evidence display renders headers/URLs/attachments/body/blast-radius safely: URLs inert copy-only, body plain-text, blast-radius unavailable state explicit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-03-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- components/phishing/classification-card.tsx
|
||||
- components/phishing/timeline-card.tsx
|
||||
autonomous: true
|
||||
requirements: [REVIEW-02, REVIEW-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "ClassificationCard shows the latest verdict (SPAM/UNWANTED/THREAT), confidence, summary, reasons list, and recommended action chips"
|
||||
- "ClassificationCard shows a requires-approval warning Alert when requiresApproval is true"
|
||||
- "TimelineCard renders a single chronological list merging linked reports, classification history, and audit events"
|
||||
artifacts:
|
||||
- path: "components/phishing/classification-card.tsx"
|
||||
provides: "read-only classification display (REVIEW-04)"
|
||||
exports: ["ClassificationCard"]
|
||||
- path: "components/phishing/timeline-card.tsx"
|
||||
provides: "chronological timeline renderer (REVIEW-02)"
|
||||
exports: ["TimelineCard"]
|
||||
key_links:
|
||||
- from: "components/phishing/timeline-card.tsx"
|
||||
to: "TimelineEntry (from plan 02 detail-route timeline)"
|
||||
via: "renders entry.kind discriminant"
|
||||
pattern: "kind"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the two remaining read-only display components: `ClassificationCard` (REVIEW-04 — latest verdict/confidence/reasons/recommended actions, with a destructive-action approval warning) and `TimelineCard` (REVIEW-02 — a single chronological list rendering the pre-merged timeline array from the extended detail route).
|
||||
|
||||
Purpose: These are pure presentational renderers of data the plan-02 route already shapes; keeping them separate from the interactive action card keeps each component focused.
|
||||
Output: 2 phishing components.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
classification (latest row, from detail route):
|
||||
{ id; verdict:'SPAM'|'UNWANTED'|'THREAT'; confidence:string|null; summary:string|null; reasons:string[]; recommendedActions:string[]; requiresApproval:boolean; createdAt }
|
||||
|
||||
timeline (from detail route, plan 02 — TimelineEntry[]):
|
||||
{ kind:'report'; at; reportId; ticketNumber; companyName }
|
||||
| { kind:'classification'; at; verdict; confidence }
|
||||
| { kind:'audit'; at; eventType; actor; payload }
|
||||
|
||||
Verdict badge recipes (UI-SPEC Color table):
|
||||
SPAM=bg-slate-500/15 text-slate-600 · UNWANTED=bg-amber-500/15 text-amber-600 · THREAT=bg-destructive/15 text-destructive
|
||||
|
||||
Audit event_type → label/icon/tint (UI-SPEC Timeline Spec):
|
||||
remediation_approved="{N} action(s) approved by {actor}" CheckCircle2 blue
|
||||
remediation_completed="Remediation completed" ShieldCheck green
|
||||
campaign_marked_false_positive="Marked as false positive" XCircle slate
|
||||
campaign_classified → classification entries; any other → humanized fallback + Circle + muted
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: ClassificationCard (REVIEW-04)</name>
|
||||
<files>components/phishing/classification-card.tsx</files>
|
||||
<read_first>
|
||||
- app/analyzer/reports/[id]/page.tsx (Card section + small presentational sub-component idiom)
|
||||
- components/ui/status-badge.tsx (verdict + action chip badge shapes)
|
||||
- components/ui/alert.tsx (the requires-approval warning Alert)
|
||||
- lib/auth-client.ts (useSession export) and lib/permissions.ts (hasPermission signature — for the Reclassify button gate)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Classification Display Spec)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in `<Card>` with `<CardTitle className="font-bold">` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">` one `<li>` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone `<Alert>`: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component renders nothing/omits itself when `classification` is null (the D-08 path handles that upstream).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "hasPermission" components/phishing/classification-card.tsx && grep -q "font-bold" components/phishing/classification-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "classification-card" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "requiresApproval" components/phishing/classification-card.tsx` succeeds and an Alert renders on that branch
|
||||
- `grep -q "hasPermission(role, 'phishing', 'analyze')" components/phishing/classification-card.tsx` (or equivalent call) — Reclassify gated on analyze
|
||||
- `grep -c "Checkbox" components/phishing/classification-card.tsx` returns 0 (no approve surface here)
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>ClassificationCard displays verdict/confidence/summary/reasons/recommended-action chips + approval warning; Reclassify gated on analyze permission.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: TimelineCard (REVIEW-02)</name>
|
||||
<files>components/phishing/timeline-card.tsx</files>
|
||||
<read_first>
|
||||
- components/ui/status-light.tsx (the 8px h-2 w-2 dot size token to reuse for the rail)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Timeline Spec — sources, event_type→icon/label/tint table, rail visual)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md ("No Analog Found" note for timeline-card — build fresh with div+Tailwind)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/phishing/timeline-card.tsx` (`'use client'`) exporting `TimelineCard({ timeline }: { timeline: TimelineEntry[] })` (define the TimelineEntry union locally mirroring the detail route's shape, or import a shared type if plan 02 exports one). Wrap in `<Card>` with `<CardTitle className="font-bold">`. Render a vertical list (already ascending/oldest-first from the server merge — do NOT re-sort) as plain `div`s + Tailwind: a left rail with an 8px dot (`h-2 w-2 rounded-full`, tinted per entry) connected by a 1px `border-l`; right side shows the label (`text-sm`), a timestamp (`font-mono text-xs text-muted-foreground`, relative with the absolute value in a `title` attr), and actor when present. Map each entry.kind: 'report' → "Report linked — Ticket #{ticketNumber} ({companyName})" icon FileText muted; 'classification' → "Classified as {verdict} ({confidence}%)" icon Sparkles tinted per verdict recipe; 'audit' → per the event_type table (remediation_approved→CheckCircle2/blue, remediation_completed→ShieldCheck/green, campaign_marked_false_positive→XCircle/slate, any other→humanized snake_case→Title Case fallback with Circle/muted). No new reusable "Timeline" primitive — this is a one-off layout.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "h-2 w-2" components/phishing/timeline-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "timeline-card" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "remediation_completed" components/phishing/timeline-card.tsx` succeeds (audit event mapping present)
|
||||
- `grep -q "border-l" components/phishing/timeline-card.tsx` succeeds (rail present)
|
||||
- The component does not call `.sort(` on the timeline array (relies on server ordering) — `grep -c "\.sort(" components/phishing/timeline-card.tsx` returns 0
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>TimelineCard renders all three merged sources chronologically with per-type icon/label/tint.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| classification/audit content → browser DOM | verdict/summary/reasons come from the classifier; audit actor/payload from audit_events — rendered as JSX text |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-10 | Tampering (XSS via classification reasons/summary or audit payload) | classification-card, timeline-card | mitigate | All fields rendered as JSX text (React auto-escapes); no `dangerouslySetInnerHTML` in either component |
|
||||
| T-22-11 | Elevation of Privilege (Reclassify shown to unauthorized role) | classification-card | mitigate | Reclassify button hidden unless `hasPermission(role,'phishing','analyze')`; server route still enforces requirePermission independently |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
- `npm test` still green
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
ClassificationCard and TimelineCard render latest classification and full chronological timeline respectively, safely and per the UI-SPEC color/typography contract.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-04-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["22-01", "22-03"]
|
||||
files_modified:
|
||||
- components/phishing/action-area-card.tsx
|
||||
autonomous: true
|
||||
requirements: [REVIEW-05, REVIEW-06]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
|
||||
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
|
||||
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
|
||||
artifacts:
|
||||
- path: "components/phishing/action-area-card.tsx"
|
||||
provides: "the only interactive remediation surface (REVIEW-05, D-03..06)"
|
||||
exports: ["ActionAreaCard"]
|
||||
key_links:
|
||||
- from: "components/phishing/action-area-card.tsx"
|
||||
to: "/api/phishing/campaigns/{id}/approve|remediate|mark-false-positive"
|
||||
via: "fetch POST then onActionComplete refetch"
|
||||
pattern: "campaigns/.+/(approve|remediate|mark-false-positive)"
|
||||
- from: "components/phishing/action-area-card.tsx"
|
||||
to: "lib/permissions.ts hasPermission"
|
||||
via: "client-side gate mirroring the server route"
|
||||
pattern: "hasPermission"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build `ActionAreaCard` — the single interactive remediation surface (REVIEW-05). It renders a checkbox list of the latest classification's recommended actions with inline-editable pre-filled params (D-03), submits exactly `ApproveActionInput[]` to the existing approve route, wires remediate + mark-false-positive (with AlertDialog confirmations), refetches after every action (D-04, no optimistic mutation), and gates all three buttons with the exact same `hasPermission()` check the server enforces (D-06/REVIEW-06), disabling-with-tooltip rather than hiding for approve/remediate.
|
||||
|
||||
Purpose: This is the phase's only new write-triggering UI. Its permission gate must never diverge from the server, and its param submission must match `remediation-service.ts` exactly.
|
||||
Output: 1 phishing component (2 tasks, same file).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
From lib/services/remediation-default-params.ts (plan 01):
|
||||
deriveDefaultParams(actionType, evidence: { requesterEmail; senderEmail; senderDomain; messageId }): Record<string, unknown>
|
||||
|
||||
From lib/services/remediation-service.ts (existing — the exact submit shape):
|
||||
ApproveActionInput { actionType: string; params?: Record<string, unknown> }
|
||||
POST /approve body: { actions: ApproveActionInput[] }
|
||||
|
||||
From lib/permissions.ts (existing, isomorphic — safe client-side):
|
||||
hasPermission(roleName: string, resource: string, action: string): boolean
|
||||
phishing actions: read | analyze | approve | remediate
|
||||
|
||||
Existing write routes (reused verbatim — NOT modified):
|
||||
POST /api/phishing/campaigns/{id}/approve -> 200 ApprovedRemediationAction[]
|
||||
POST /api/phishing/campaigns/{id}/remediate (no body) -> 200 { campaignId, actions:[{id,actionType,status,alreadyCompleted}] }
|
||||
POST /api/phishing/campaigns/{id}/mark-false-positive { reason? } -> 200 { campaignId, status:'false_positive', auditEventId }; 409 if approved/completed remediation exists
|
||||
|
||||
Props (from the review page, plan 06): { campaignId, classification (latest, with recommendedActions), remediationActions (with status + completedAt + approvedBy), campaignStatus, campaignUpdatedAt, evidence (requesterEmail/senderEmail/senderDomain/messageId derived from campaign detail), onActionComplete }
|
||||
|
||||
Analog to copy: components/rmm/rmm-dispatch-dialog.tsx (useSession + disabled-button + fetch-POST-toast). Tooltip primitive: components/ui/tooltip.tsx (plan 03). AlertDialog: components/ui/alert-dialog.tsx.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Checkbox list + editable params + Approve selected (D-03)</name>
|
||||
<files>components/phishing/action-area-card.tsx</files>
|
||||
<read_first>
|
||||
- components/rmm/rmm-dispatch-dialog.tsx (useSession + fetch-POST-toast structure to copy)
|
||||
- lib/services/remediation-default-params.ts (plan 01 — deriveDefaultParams)
|
||||
- components/ui/checkbox.tsx (shadcn checkbox usage)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `<Card>` with `<CardTitle className="font-bold">` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). Render one row per string in `classification.recommendedActions`: a shadcn `<Checkbox>` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds
|
||||
- `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params`
|
||||
- `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic)
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>Checkbox list with editable pre-filled params submits exact ApproveActionInput[] and refetches on success.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Remediate + mark-false-positive + permission/resolved gating (REVIEW-06, D-05, D-06)</name>
|
||||
<files>components/phishing/action-area-card.tsx</files>
|
||||
<read_first>
|
||||
- components/phishing/action-area-card.tsx (task 1 output — extend the same file)
|
||||
- components/ui/alert-dialog.tsx (confirmation dialog usage)
|
||||
- components/ui/tooltip.tsx (plan 03 — disabled-button explanations)
|
||||
- lib/permissions.ts + lib/auth-client.ts (hasPermission + useSession)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — Buttons section, priority-ordered disable reasons, Copywriting Contract tooltips)
|
||||
</read_first>
|
||||
<action>
|
||||
Extend `action-area-card.tsx`. Derive role via `useSession()`: `const role = (session?.user as {role?:string})?.role ?? 'user'`; `const canApprove = hasPermission(role, 'phishing', 'approve')`; `const canRemediate = hasPermission(role, 'phishing', 'remediate')`. Define `resolved` = `campaignStatus === 'false_positive' || remediationActions.some(a => a.status === 'completed')`. Add two more buttons, all three ALWAYS rendered (never removed from DOM, D-05), each wrapped in a `Tooltip` when disabled. Evaluate each button's disabled reason in this priority order (first match wins, shown as the tooltip):
|
||||
"Approve selected" (primary): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) zero checked → "Select at least one action to approve"; else enabled.
|
||||
"Remediate approved actions" (outline): (1) !canRemediate → "Requires remediate permission"; (2) resolved → resolved-state copy; (3) no remediationActions row with status==='approved' → "No approved actions to remediate"; else enabled — on click open an AlertDialog ("Remediate {N} approved action(s): {humanized types}. This executes the simulated remediation effect and cannot be undone." confirm label "Remediate", destructive variant) then POST `/api/phishing/campaigns/${campaignId}/remediate` (no body), toast + onActionComplete.
|
||||
"Mark as false positive" (destructive): (1) !canApprove → "Requires approve permission"; (2) resolved → resolved-state copy; (3) any remediationActions row with status in ('approved','completed') → "Cannot mark false positive — this campaign already has approved or completed remediation" (mirrors the server 409 guard exactly); else enabled — on click open an AlertDialog ("Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it later." confirm label "Mark as false positive", destructive) with an optional reason Textarea, then POST `/api/phishing/campaigns/${campaignId}/mark-false-positive` with `{ reason? }`, toast + onActionComplete. Resolved-state tooltip copy: remediated → "Already remediated on {completedAt date} by {approvedBy}" (derive from the completed remediationActions row); false positive → "Marked as false positive on {campaignUpdatedAt date}". All permission checks use `hasPermission()` from lib/permissions.ts (never a bespoke `role === 'admin'` check) so client and server can never drift (REVIEW-06).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx && grep -q "hasPermission(role, 'phishing', 'remediate')" components/phishing/action-area-card.tsx && grep -q "mark-false-positive" components/phishing/action-area-card.tsx && ! grep -q "role === 'admin'" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "hasPermission(role, 'phishing', 'approve')" components/phishing/action-area-card.tsx` and `...'remediate')` both succeed
|
||||
- `grep -c "role === 'admin'" components/phishing/action-area-card.tsx` returns 0 (no bespoke role check that could diverge from server)
|
||||
- `grep -q "/remediate" components/phishing/action-area-card.tsx` and `grep -q "/mark-false-positive" components/phishing/action-area-card.tsx` succeed
|
||||
- `grep -q "AlertDialog" components/phishing/action-area-card.tsx` succeeds (both destructive actions confirm)
|
||||
- All three buttons render regardless of state (disabled, not removed) — verified by the absence of a conditional that omits a button when `resolved` (reviewer/manual check)
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>All three actions gated by the exact server hasPermission check, disabled-with-tooltip when blocked, destructive actions confirmed, refetch on success.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser→API (write) | Operator triggers approve/remediate/mark-false-positive; the client gate is UX-only, the server route is the enforcement boundary |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-12 | Elevation of Privilege (unauthorized operator triggering a write) | action-area-card.tsx | mitigate | Client-side `hasPermission(role,'phishing','approve'|'remediate')` mirrors the server's `requirePermission` exactly (same function family); server remains the sole enforcement point — the client gate only prevents a failed request/UX confusion (REVIEW-06) |
|
||||
| T-22-13 | Tampering (client/server permission drift) | action-area-card.tsx | mitigate | Uses `hasPermission()` from lib/permissions.ts — the identical statement the routes enforce — not a bespoke `role === 'admin'` string check (which could drift) |
|
||||
| T-22-14 | Tampering (irreversible action mis-click) | remediate + mark-false-positive | mitigate | Both destructive transitions require an AlertDialog confirm before the POST; server-side idempotency (remediate) and the 409 guard (mark-false-positive) provide the real backstop |
|
||||
| T-22-15 | Tampering (CSRF on write routes) | action-area-card.tsx | accept | Same-origin `fetch()` from a Better-Auth-session-cookie'd page, matching every existing write route's CSRF posture; no change introduced or required by this phase (22-RESEARCH Security Domain) |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
- `npm test` still green
|
||||
- Manual: as a `user` role, confirm approve/remediate/mark-false-positive are disabled with the correct tooltip; as admin, confirm approve→remediate→resolved flow disables buttons post-resolution
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The action card submits exact ApproveActionInput[], wires remediate + mark-false-positive with confirmations and refetch, and disables-with-tooltip using the same permission function the server enforces.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-05-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["22-02", "22-03", "22-04", "22-05"]
|
||||
files_modified:
|
||||
- app/phishing/tickets/[ticketId]/page.tsx
|
||||
- app/phishing/page.tsx
|
||||
- components/navigation/app-navigation.tsx
|
||||
autonomous: true
|
||||
requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
|
||||
- "The review page renders classification, action area, evidence, and timeline for a grouped campaign; a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
|
||||
- "After any approve/remediate/mark-false-positive succeeds, the page refetches and re-renders from fresh server state"
|
||||
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
|
||||
- "A 'Phishing' nav entry links to /phishing"
|
||||
artifacts:
|
||||
- path: "app/phishing/tickets/[ticketId]/page.tsx"
|
||||
provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)"
|
||||
min_lines: 60
|
||||
- path: "app/phishing/page.tsx"
|
||||
provides: "minimal campaigns list page (D-00)"
|
||||
min_lines: 30
|
||||
- path: "components/navigation/app-navigation.tsx"
|
||||
provides: "Phishing nav entry (D-02)"
|
||||
contains: "/phishing"
|
||||
key_links:
|
||||
- from: "app/phishing/tickets/[ticketId]/page.tsx"
|
||||
to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}"
|
||||
via: "two-step fetch in load()"
|
||||
pattern: "tickets/.+/campaign"
|
||||
- from: "app/phishing/page.tsx"
|
||||
to: "/phishing/tickets/{firstReportTicketId}"
|
||||
via: "router.push on row click"
|
||||
pattern: "firstReportTicketId"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a five-state machine (loading / not-triaged / ungrouped / ready / error), composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable.
|
||||
|
||||
Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces.
|
||||
Output: 2 pages + 1 nav edit.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
|
||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
Routes consumed (plan 02):
|
||||
GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? }
|
||||
GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports + messages(headers/urls/attachments/bodyPreview) + indicators + classifications + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
|
||||
GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset }
|
||||
POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA)
|
||||
|
||||
Components consumed:
|
||||
components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05)
|
||||
components/admin/DataTable.tsx (Column<TData>[], data, totalCount, page, pageSize, onPageChange, onRowClick)
|
||||
components/ui/{empty-state,alert,status-badge,skeleton-helpers}.tsx, components/navigation/page-header.tsx
|
||||
|
||||
Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX8 entry (title/href/icon/description) is the exact shape; insert the new item immediately after PAX8, before "Backup Status". Add ShieldAlert to the lucide-react import block. No role gate (every role has phishing:read).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Ticket-scoped review page (REVIEW-01, REVIEW-05, REVIEW-06)</name>
|
||||
<files>app/phishing/tickets/[ticketId]/page.tsx</files>
|
||||
<read_first>
|
||||
- app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom)
|
||||
- components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here)
|
||||
- components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States, Copywriting Contract)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `<PageHeader title="Ticket #{ticketNumber}" description={humanized campaign status} breadcrumbs={['Phishing','Ticket #{ticketNumber}']} />` inside `<main className="container mx-auto px-6 py-6 space-y-6">` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`. Render per state: loading → `SkeletonHeader` + 3× `SkeletonCard`; not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()); ungrouped (D-08) → info-tone `<Alert>` with the D-08 copy followed by `<EvidenceCard>` alone (no classification/action/timeline cards); error → destructive `<Alert>` with the error copy + "Retry" button re-running load(); ready → in order: `<ClassificationCard>` (full width), `<ActionAreaCard onActionComplete={load}>` (full width, pass campaignId/classification/remediationActions/campaignStatus/campaignUpdatedAt and the derived evidence object {requesterEmail,senderEmail,senderDomain,messageId}), then `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard/><TimelineCard/></div>`. Pass `load` as `onActionComplete`/`onReclassified` so every action refetches (D-04, no optimistic mutation). Derive the ActionAreaCard `evidence` fields from the campaign detail's primary message headers + primary report requesterEmail. Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present)
|
||||
- `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA)
|
||||
- `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04)
|
||||
- `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01)
|
||||
- All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported and rendered
|
||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||
</acceptance_criteria>
|
||||
<done>Review page resolves ticket→campaign, renders all four states + ready layout, refetches after every action, Better-Auth-session-only.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Campaigns list page (D-00) + Phishing nav entry (D-02)</name>
|
||||
<files>app/phishing/page.tsx, components/navigation/app-navigation.tsx</files>
|
||||
<read_first>
|
||||
- app/admin/data-browser/companies/page.tsx (DataTable + pagination-state + row-click-navigate idiom)
|
||||
- components/admin/DataTable.tsx (confirmed props: columns/data/totalCount/page/pageSize/onPageChange/onRowClick)
|
||||
- components/ui/empty-state.tsx, components/ui/status-badge.tsx, components/navigation/page-header.tsx
|
||||
- components/navigation/app-navigation.tsx (the navigationItems array + the PAX8 entry shape + the lucide-react import block)
|
||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Campaigns list page layout + columns + Nav placement)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `app/phishing/page.tsx` (`'use client'`) with `<PageHeader title="Phishing Campaigns" description="Automatically detected phishing and spam campaigns awaiting triage." />` inside `<main className="container mx-auto px-6 py-6">`. Hold `campaigns`/`totalCount`/`page`/`pageSize(50)`/`isLoading` state; `fetchCampaigns(page)` calls `/api/phishing/campaigns?limit=50&offset={(page-1)*50}` and sets `items`/`total`. Render `<DataTable>` with columns per the UI-SPEC table: Campaign (`campaignKey` fallback "Campaign {id.slice(0,8)}", `font-mono text-sm`), Status (`StatusBadge` per the color table: open/proposed slate, false_positive slate, etc.), Reports (`reportCount`, right-aligned), First seen (`firstSeenAt`, relative + absolute title, `font-mono text-xs`), Last seen (`lastSeenAt`, same). `onRowClick` uses `useRouter().push('/phishing/tickets/' + campaign.firstReportTicketId)`. When `items.length === 0` render `<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />` in place of the table. Then MODIFY `components/navigation/app-navigation.tsx`: add `ShieldAlert` to the lucide-react import block and insert a new `navigationItems` entry immediately after the PAX8 item and before "Backup Status": `{ title: 'Phishing', href: '/phishing', icon: ShieldAlert, description: 'Phishing/spam campaign triage, evidence, and remediation approval' }`. Do NOT add it to any super-admin/role-gated filter — it is visible to all roles (every role has phishing:read).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "firstReportTicketId" app/phishing/page.tsx && grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx && grep -q "ShieldAlert" components/navigation/app-navigation.tsx && npx tsc --noEmit --pretty 2>&1 | grep -Ec "app/phishing/page|app-navigation" | grep -qx 0 && echo OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "firstReportTicketId" app/phishing/page.tsx` succeeds (row-click navigates via ticket id, not a campaign-scoped page)
|
||||
- `grep -q "/api/phishing/campaigns" app/phishing/page.tsx` succeeds (reuses the existing list endpoint)
|
||||
- `grep -q "title: 'Phishing'" components/navigation/app-navigation.tsx` and `grep -q "href: '/phishing'" ...` succeed
|
||||
- `grep -q "EmptyState" app/phishing/page.tsx` succeeds (empty state present)
|
||||
- `npx tsc --noEmit --pretty` reports no error in either file
|
||||
</acceptance_criteria>
|
||||
<done>Campaigns list page browses recent campaigns and navigates to the ticket-scoped review page; Phishing nav entry present for all roles.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser (unauth) → page | Middleware redirects unauthenticated requests to /auth/sign-in; /phishing is not in publicRoutes |
|
||||
| LiveLink-supplied ticket id → page | The numeric AT ticket id is untrusted URL input, validated server-side by the resolver route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added |
|
||||
| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the page adds no relaxed check and the server routes enforce independently |
|
||||
| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML |
|
||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` clean
|
||||
- `npm test` full suite green
|
||||
- Manual click-through (per 22-VALIDATION Manual-Only): review page in all four states (full-campaign, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Both operator surfaces work end-to-end: the LiveLink review page (all states, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-06-SUMMARY.md` when done
|
||||
</output>
|
||||
|
|
@ -23,7 +23,7 @@ created: 2026-07-16
|
|||
| **Full suite command** | `npm test` |
|
||||
| **Estimated runtime** | ~30 seconds |
|
||||
|
||||
**Confirmed constraint:** `vitest.config.ts`'s `test.include` is `['lib/**/*.test.ts']` only — it does NOT include `app/**` or `components/**`. React page/component code and Next.js API route handlers in this phase have **no automated test coverage under the current config**, consistent with CLAUDE.md's stated safety net (`npx tsc --noEmit --pretty`) for untested areas. Any genuinely new *pure logic* this phase introduces should be extracted into a `lib/services/*.ts` file specifically so it can be unit-tested; UI composition and route wiring fall back to type-check as the safety net.
|
||||
**Confirmed constraint:** `vitest.config.ts`'s `test.include` is `['lib/**/*.test.ts']` only — it does NOT include `app/**` or `components/**`. React page/component code and Next.js API route handlers in this phase have **no automated test coverage under the current config**, consistent with CLAUDE.md's stated safety net (`npx tsc --noEmit --pretty`) for untested areas. Any genuinely new *pure logic* this phase introduces is extracted into a `lib/services/*.ts` file (plan 22-01) so it can be unit-tested; UI composition and route wiring fall back to type-check as the safety net.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -40,23 +40,27 @@ created: 2026-07-16
|
|||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| TBD-resolver | TBD | TBD | REVIEW-01 | V5 Input Validation | `ticket_id` validated `Number.isFinite()` matching `analyze/route.ts` idiom; returns correct `{found, reportId, campaignId}` shape for no-report / ungrouped-report / grouped-campaign | unit | `npx vitest run lib/services/phishing-ticket-resolver.test.ts` | ❌ Wave 0 — extract resolver SQL into testable `lib/services/phishing-ticket-resolver.ts` | ⬜ pending |
|
||||
| TBD-defaults | TBD | TBD | REVIEW-04 | — | Default-param derivation table (7 action types → client-side default params) matches `ApproveActionInput` shape exactly | unit | `npx vitest run lib/services/remediation-default-params.test.ts` | ❌ Wave 0 — extract UI-SPEC's Action Area default-param logic into a pure function | ⬜ pending |
|
||||
| TBD-timeline | TBD | TBD | REVIEW-02 | — | Timeline merge/sort (reports + classifications + audit_events, chronological) — only if server-merged | unit | `npx vitest run lib/services/phishing-timeline.test.ts` | ❌ Wave 0 — only if planner chooses server-merge approach | ⬜ pending |
|
||||
| TBD-page | TBD | TBD | REVIEW-03, REVIEW-05, REVIEW-06 | V4 Access Control, Tampering (XSS) | Page rendering, action button gating/disabling per `hasPermission()`, evidence display (no `dangerouslySetInnerHTML`, URLs inert-text-only per D-09) | manual | — | N/A — no `app/**`/`components/**` test infra | ⬜ pending |
|
||||
| 22-01-T1 (resolver) | 22-01 | 1 | REVIEW-01 | T-22-01/02 (V5 Input Validation) | `resolveTicketToCampaign` returns correct `{found, reportId, campaignId, ticketNumber}` for no-report / ungrouped-report / grouped-campaign; parameterized query only | unit | `npx vitest run lib/services/phishing-ticket-resolver.test.ts` | ❌ Wave 0 — created in 22-01 T1 | ⬜ pending |
|
||||
| 22-01-T2 (defaults) | 22-01 | 1 | REVIEW-04 | — | Default-param derivation (7 action types + unknown fallback) matches `ApproveActionInput` params shape exactly | unit | `npx vitest run lib/services/remediation-default-params.test.ts` | ❌ Wave 0 — created in 22-01 T2 | ⬜ pending |
|
||||
| 22-01-T3 (timeline) | 22-01 | 1 | REVIEW-02 | — | `mergeTimeline` merges reports + classifications + audit_events into one ascending chronological array (server-merge approach chosen) | unit | `npx vitest run lib/services/phishing-timeline.test.ts` | ❌ Wave 0 — created in 22-01 T3 | ⬜ pending |
|
||||
| 22-02-T1/T2/T3 (routes) | 22-02 | 2 | REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04 | T-22-03..06 (V4 Access Control, V5, Info Disclosure) | Resolver + extended detail + extended list routes; requirePermission gates; fresh blastRadius scoped to real sender/recipient (not empty-string); no `completed_at` column reference | type-check (no route test infra) | `npx tsc --noEmit --pretty` | N/A — no `app/**` test infra | ⬜ pending |
|
||||
| 22-03-T1/T2 (evidence) | 22-03 | 1 | REVIEW-03 | T-22-07/08/09 (Tampering/XSS) | URLs inert copy-only (no `<a href>`, D-09); body preview plain `<pre>`, no `dangerouslySetInnerHTML`; attachment metadata only; blast-radius unavailable state explicit | manual + grep gates | `grep`-based DOM-safety assertions in plan acceptance_criteria | N/A — no `components/**` test infra | ⬜ pending |
|
||||
| 22-04-T1/T2 (class/timeline UI) | 22-04 | 1 | REVIEW-02, REVIEW-04 | T-22-10/11 | ClassificationCard verdict/reasons/actions + approval warning; Reclassify gated on analyze; TimelineCard renders 3 merged sources | manual | — | N/A — no `components/**` test infra | ⬜ pending |
|
||||
| 22-05-T1/T2 (action card) | 22-05 | 2 | REVIEW-05, REVIEW-06 | T-22-12..15 (Elevation, Tampering, CSRF) | Approve submits exact `ApproveActionInput[]`; remediate/mark-false-positive confirmed + refetch (D-04); all buttons gated by server-identical `hasPermission()`, disabled-with-tooltip (no bespoke `role === 'admin'`) | manual (+ grep gate on `hasPermission`) | `grep`-based permission-gate assertions in plan acceptance_criteria | N/A — no `components/**` test infra | ⬜ pending |
|
||||
| 22-06-T1/T2 (pages+nav) | 22-06 | 3 | REVIEW-01, REVIEW-05, REVIEW-06 | T-22-16/17/18 (Spoofing/Elevation/Info Disclosure) | Ticket→campaign two-step fetch, 5-state machine, refetch after action, Better-Auth-session-only (no token/query-param auth); list-page row-click → `/phishing/tickets/{firstReportTicketId}`; nav entry | manual | — | N/A — no `app/**` test infra | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
*Task IDs are placeholders (TBD-*) pending the planner's actual plan/task numbering — the planner must populate this table's Task ID / Plan / Wave columns to match the real PLAN.md files it produces.*
|
||||
**Server-merge decision:** The planner chose the server-merge approach for the timeline (RESEARCH.md Alternatives Considered), so `lib/services/phishing-timeline.ts` + test IS created (22-01 T3) — the conditional Wave 0 item is now a firm requirement.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `lib/services/phishing-ticket-resolver.ts` + `lib/services/phishing-ticket-resolver.test.ts` — ticket→campaign resolution as a testable pure function (REVIEW-01), not inline route logic
|
||||
- [ ] `lib/services/remediation-default-params.ts` + `.test.ts` — 7-action-type default-param derivation table extracted from UI-SPEC's Action Area spec (REVIEW-04), not inline component logic
|
||||
- [ ] (Conditional) `lib/services/phishing-timeline.ts` + `.test.ts` — only if the planner chooses to server-merge the timeline (REVIEW-02) rather than merge client-side
|
||||
- [ ] No framework install needed — vitest already configured and passing for the rest of the codebase
|
||||
- [x] `lib/services/phishing-ticket-resolver.ts` + `.test.ts` — ticket→campaign resolution as a testable pure function (REVIEW-01) → **22-01 Task 1**
|
||||
- [x] `lib/services/remediation-default-params.ts` + `.test.ts` — 7-action-type default-param derivation extracted from UI-SPEC's Action Area spec (REVIEW-04) → **22-01 Task 2**
|
||||
- [x] `lib/services/phishing-timeline.ts` + `.test.ts` — server-merge timeline (REVIEW-02) chosen → **22-01 Task 3**
|
||||
- [x] No framework install needed — vitest already configured and passing for the rest of the codebase
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -74,11 +78,11 @@ created: 2026-07-16
|
|||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have automated verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 30s
|
||||
- [x] All tasks have automated verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify (Wave 0 unit tests + per-task type-check + grep DOM/permission gates)
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 30s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue