chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
parent
b638189cb0
commit
672f17b7f9
35 changed files with 2801 additions and 92 deletions
|
|
@ -36,4 +36,4 @@
|
|||
"agent_skills": {},
|
||||
"mode": "yolo",
|
||||
"granularity": "standard"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
121
.planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md
Normal file
121
.planning/phases/16-eml-mime-evidence-parser/16-VERIFICATION.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
phase: 16-eml-mime-evidence-parser
|
||||
verified: 2026-07-15T14:44:12Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Phase 16: EML/MIME Evidence Parser Verification Report
|
||||
|
||||
**Phase Goal:** Given a ticket's attachments, Pulse selects the correct original reported message and parses its RFC822/MIME structure into normalized, actionable evidence — without ever executing or fetching anything from the message.
|
||||
**Verified:** 2026-07-15T14:44:12Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (ROADMAP Success Criteria + PLAN must_haves, merged)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Given synthetic fixtures with both `rfc.eml` and `OriginatingEmail.eml` present, selection picks `rfc.eml` matching by `message/rfc822` content-type, not filename alone | ✓ VERIFIED | `lib/services/eml-parser.ts:50-65` `selectOriginalMessage` filters `isMessageRfc822` first, then matches exact `rfc.eml`; test `eml-parser.test.ts:28-37` asserts tier 1 + case-insensitive tier 1; also covers KnowBe4 tier 2 (`:39-43`) and OriginatingEmail-only tier 3 (`:45-49`) and ambiguous/null cases (`:51-69`). All pass live (`npx vitest run` — 49/49 across the 4 phase test files). |
|
||||
| 2 | Parsing a synthetic `.eml` fixture produces normalized headers (From/displayName/senderEmail/senderDomain/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received chain, SPF/DKIM/DMARC), URLs, and attachment metadata (name/content-type/size/hash) | ✓ VERIFIED | `parseEml` (`eml-parser.ts:227-282`) builds exactly this shape from `mailparser`'s `ParsedMail`; test `eml-parser.test.ts:137-164` asserts every field against `RICH_MULTIPART_EML`, including `authResults` structured verdicts (not raw text), sha256 checksum format, and `related` flag. Runs against real `simpleParser` (no mocking of the parser itself) — not a tautology. |
|
||||
| 3 | The parser never executes or fetches any URL found in a message — verified by tests asserting no outbound network calls happen during parsing | ✓ VERIFIED | `eml-parser.test.ts:188-197` spies on `global.fetch` across 5 fixture parses and asserts zero calls; `extractUrls`/`buildBodyPreview` are pure string operations (`eml-parser.ts:140-184`) with no fetch/render path. Orchestration-level (service) invariant separately verified below (Truth 6). |
|
||||
| 4 | Parsed output includes a sanitized/truncated body preview stored alongside raw evidence, distinct from the full raw body | ✓ VERIFIED | `buildBodyPreview` (`eml-parser.ts:176-184`) truncates to `MAX_BODY_PREVIEW_LENGTH=500` and strips HTML via `stripHtmlToText`; test `eml-parser.test.ts:128-133,178-181` confirms truncation and distinctness on a 2000-char fixture. Raw bytes are never persisted to Postgres — only to B2 (`raw_ref` is an object key, `phishing-eml-service.ts:97-106,142-157`), consistent with D-05. |
|
||||
| 5 | `npx vitest run` for the new parser test file passes using synthetic fixtures only (no real customer email) | ✓ VERIFIED | Ran live: `eml-parser.test.ts` 26/26 pass. A dedicated test (`eml-parser.test.ts:205-218`) asserts no fixture contains `wulfconsulting.com` and all use RFC 2606 reserved `.test`/`.com`-fake (`evil-example.test`) domains. `deferred-items.md` documents the 2 pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` — last touched at commit `a0a6e7f`/`8f8b5ab`, predating all Phase 16 commits). |
|
||||
| 6 | (16-02/16-03 supporting truth) AutotaskClient.getAttachmentContent reads `response.items?.[0]`, not `.item`; b2 EML_OBJECT_KEY_REGEX enforces path-traversal-safe `.eml` keys in parallel with the untouched LogLift regex; migration 099 adds `indicators.metadata` JSONB | ✓ VERIFIED | `autotask-client.ts:443-456` reads `response.items?.[0] ?? null`; test asserts an `{item:...}`-shaped response yields null (guards the exact regression risk called out in the plan). `b2/client.ts:41-42` adds `EML_OBJECT_KEY_REGEX` beside the unmodified `OBJECT_KEY_REGEX` (`:31-32`); `presignDownload`/`presignUpload`/`downloadToBuffer` (`:155-218`) take an optional `keyRegex` param defaulting to `OBJECT_KEY_REGEX`; `rmm/executor.ts:337`'s existing 2-arg call site still compiles (confirmed via `tsc --noEmit`, exit 0). Migration 099 applied live: `information_schema.columns` on the dev DB (`pulse-postgres`) reports `indicators.metadata` as `jsonb`. |
|
||||
| 7 | Orchestration service (16-03) wires list→select→fetch→(B2 gated)→parse→persist end to end, writing one `messages` row + `indicators` rows with D-07 metadata, and no-op (no throw) when no `.eml` attachment or B2 unconfigured | ✓ VERIFIED | `phishing-eml-service.ts:49-224` imports and calls `getAutotaskClient().getAttachments`, `selectOriginalMessage`, `getAttachmentContent`, `isB2Configured`/`presignUpload`/`EML_OBJECT_KEY_REGEX`, `parseEml` — exact function signatures match Plan 01/02's exports (verified by reading both source files side by side). `messages` INSERT (`:142-157`) and 3 `indicators` INSERT loops (`:164-211`) match migration 097/099's actual column list (`report_id, message_id, headers, urls, attachments, body_preview, raw_ref` / `message_id, indicator_type, value, metadata`). Test suite (`phishing-eml-service.test.ts`, 6/6 passing) exercises the happy path (real `parseEml` invoked on `RICH_MULTIPART_EML`, only DB/Autotask/B2 boundaries mocked — not tautological), no-eml no-op, B2-configured/unconfigured branches, no-network invariant, and indicator metadata shape. |
|
||||
| 8 | No outbound network call is made to any URL found in the message, end to end (service level) | ✓ VERIFIED | `phishing-eml-service.test.ts:155-170` asserts `global.fetch` is called exactly once (the B2 presigned PUT) and never with the fixture's embedded body URL (`http://evil-example.test/verify`). Source review confirms the only `fetch(` call in `phishing-eml-service.ts` targets `uploadUrl` (a B2-presigned URL Pulse itself constructed), never a value derived from `normalized.urls`. |
|
||||
|
||||
**Score:** 8/8 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `lib/services/eml-parser.ts` | parseEml, selectOriginalMessage, parseAuthResults, extractUrls, buildBodyPreview, NormalizedMessage (min 120 lines) | ✓ VERIFIED | 282 lines; all 5 functions + types exported exactly per plan interface contract |
|
||||
| `lib/services/eml-parser.fixtures.ts` | synthetic fixtures for all 3 selection tiers + parse fixtures | ✓ VERIFIED | Exists, imported and exercised by both `eml-parser.test.ts` and `phishing-eml-service.test.ts`; RFC 2606-safe synthetic content only |
|
||||
| `lib/services/eml-parser.test.ts` | EVID-02/03/04 vitest coverage incl. no-network spy + 3-tier selection (min 100 lines) | ✓ VERIFIED | 219 lines, 26 tests, all passing live |
|
||||
| `lib/services/autotask-client.ts` | getAttachmentContent(entityName, entityId, attachmentId) | ✓ VERIFIED | Present at line 443, reads `items?.[0] ?? null` |
|
||||
| `lib/services/autotask-client.test.ts` | first AutotaskClient unit coverage — items[0] behavior (min 30 lines) | ✓ VERIFIED | 73 lines, 3 tests, all passing |
|
||||
| `lib/services/b2/client.ts` | EML_OBJECT_KEY_REGEX + parameterized key validation | ✓ VERIFIED | Present, OBJECT_KEY_REGEX untouched (git-diff-style visual check against current content confirms LogLift regex line unchanged), all 3 functions parameterized |
|
||||
| `migrations/099_indicators_metadata.sql` | ALTER TABLE indicators ADD COLUMN metadata JSONB | ✓ VERIFIED | Present, applied live to dev DB (confirmed via information_schema query) |
|
||||
| `lib/services/phishing-eml-service.ts` | parseAndStoreMessage orchestration (min 90 lines) | ✓ VERIFIED | 224 lines, full list→select→fetch→size-guard→B2→parse→persist flow |
|
||||
| `lib/services/phishing-eml-service.test.ts` | orchestration coverage with mocked autotask/b2/postgres incl. no-network + B2-gated + no-eml no-op (min 70 lines) | ✓ VERIFIED | 195 lines, 6 tests, all passing |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `eml-parser.ts` | `mailparser` | `simpleParser` import w/ `checksumAlgo: 'sha256'` | ✓ WIRED | `eml-parser.ts:21,236` |
|
||||
| `eml-parser.test.ts` | `global.fetch` | `vi.spyOn` zero-call assertion | ✓ WIRED | `eml-parser.test.ts:188-197` |
|
||||
| `autotask-client.ts getAttachmentContent` | Autotask `Tickets/{id}/Attachments/{attachmentId}` | `makeApiCall` GET, `items?.[0]` | ✓ WIRED | `autotask-client.ts:443-456`; test confirms both shapes |
|
||||
| `b2/client.ts presignUpload` | `EML_OBJECT_KEY_REGEX` | optional `keyRegex` param | ✓ WIRED | `b2/client.ts:165-173` |
|
||||
| `phishing-eml-service.ts` | `eml-parser.ts` | `parseEml`+`selectOriginalMessage` import | ✓ WIRED | `phishing-eml-service.ts:27`; both invoked in the real flow (not stubbed in production code) |
|
||||
| `phishing-eml-service.ts` | `AutotaskClient.getAttachmentContent` | `getAutotaskClient().getAttachmentContent(...)` | ✓ WIRED | `phishing-eml-service.ts:65-69` |
|
||||
| `phishing-eml-service.ts` | `messages`/`indicators` tables | `postgresClient.query` INSERT ... RETURNING id::text | ✓ WIRED | `phishing-eml-service.ts:142-157,166-211`; column lists match migration 097+099 schema exactly |
|
||||
| `phishing-eml-service.ts` | B2 (`presignUpload`+`EML_OBJECT_KEY_REGEX`) | `isB2Configured` gate then self-PUT | ✓ WIRED | `phishing-eml-service.ts:97-122`; gated, graceful-degrades on failure |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
Not applicable in the traditional UI-rendering sense — this phase is a pure backend service/library. Instead, traced data flow through the orchestration pipeline directly:
|
||||
|
||||
| Stage | Input | Output | Verified Real (not hardcoded) |
|
||||
|-------|-------|--------|-------------------------------|
|
||||
| `getAttachments` → `selectOriginalMessage` | live Autotask attachment list | selected `Attachment \| null` | ✓ Algorithm is a real filter/find chain over the input array, not a static return |
|
||||
| `getAttachmentContent` → `Buffer.from(data,'base64')` | live Autotask base64 payload | decoded raw bytes | ✓ Real base64 decode, test proves items[0]-vs-item distinction |
|
||||
| `parseEml` → `messages` INSERT | real `simpleParser` output | JSONB headers/urls/attachments payload | ✓ `headersPayload` built field-by-field from `normalized.*`, not a static object; test asserts `authResults` reaches the persisted param |
|
||||
| `normalized.attachments/urls/from` → `indicators` INSERT loops | parsed message | per-indicator rows w/ metadata | ✓ Loops iterate real arrays from the parse result; test asserts attachment_hash metadata shape from an actual parsed fixture |
|
||||
|
||||
No hollow/static-return patterns found in the traced path.
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full phase-16 vitest suite | `npx vitest run lib/services/eml-parser.test.ts lib/services/autotask-client.test.ts lib/services/b2/client.test.ts lib/services/phishing-eml-service.test.ts` | 4 files, 49/49 tests passed | ✓ PASS |
|
||||
| Repo-wide type check | `npx tsc --noEmit --pretty` | exit 0, no output | ✓ PASS |
|
||||
| Full test suite (regression check) | `npm test` | 25/26 files pass, 282/284 tests pass; only pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` predating Phase 16 commits) | ✓ PASS (no phase-16 regressions) |
|
||||
| Migration 099 applied to dev DB | `docker exec pulse-postgres psql ... information_schema.columns` | returns `jsonb` | ✓ PASS |
|
||||
| `rmm/executor.ts` presignUpload call site unaffected | `grep presignUpload lib/services/rmm/executor.ts` + tsc clean | 2-arg call, `presignUpload(objectKey, 1800)`, compiles | ✓ PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` probes declared or discovered for this phase; not a migration/tooling phase in that sense. SKIPPED (no probe files apply — verification instead relied on the project's own vitest suite, run directly by the verifier, not narrated by SUMMARY.md).
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| EVID-02 | 16-01 | Prefer `rfc.eml` over `OriginatingEmail.eml`, matching by content-type not filename alone | ✓ SATISFIED | `selectOriginalMessage` three-tier algorithm + full test coverage |
|
||||
| EVID-03 | 16-01, 16-02, 16-03 | Parse RFC822/MIME into normalized headers/auth-verdicts/URLs/attachment metadata, persisted | ✓ SATISFIED | `parseEml` + `messages`/`indicators` persistence, both tested |
|
||||
| EVID-04 | 16-01, 16-02, 16-03 | Sanitized/truncated body preview stored alongside raw evidence; never fetch/execute message content | ✓ SATISFIED | `buildBodyPreview` + B2 raw storage (D-05) + no-network spy at both parser and service level |
|
||||
|
||||
Note: `.planning/REQUIREMENTS.md` still shows EVID-02/03/04 as unchecked/"Pending" — this appears to be a tracking-doc staleness issue (the doc is not updated by the execute-phase workflow), not evidence of non-completion. All three requirements are satisfied by the code and tests as verified above.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None. Scanned all 10 phase-modified/created files for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER`, "not yet implemented", "coming soon" — zero matches.
|
||||
|
||||
### Deviations Reviewed (from SUMMARY.md, checked against CLAUDE.md conventions)
|
||||
|
||||
1. **16-01: hand-rolled HTML stripper instead of `html-to-text`.** Acceptable — avoids depending on an unpinned transitive dependency; consistent with "don't introduce dependencies not required" project posture; fully test-covered.
|
||||
2. **16-01: test title renamed for `-t` filter discoverability.** Cosmetic, no behavior change.
|
||||
3. **16-02: migration 099 applied via direct `docker exec` instead of `scripts/apply-migrations.sh`.** Consistent with CLAUDE.md's own documented caveat ("check first; behavior varies") — the script's hardcoded `MIGRATIONS_DIR` didn't see the worktree's file; the same real credentials (`pulse_user`/`pulse_autotask`) were used, not fallback defaults. Verified live on the dev DB.
|
||||
4. **16-03: self-PUT to B2 (first instance of Pulse's own server code PUTting to B2, vs. handing a presigned URL to an external collector).** Explicitly anticipated and researched in 16-RESEARCH.md as a deliberate new pattern, not an ad hoc deviation; gated behind `isB2Configured()` with graceful degrade on failure.
|
||||
5. **16-03: Task 1/Task 2 executed as separate implementation-then-test-suite commits rather than interleaved RED/GREEN within one task.** Matches how the plan's own task structure was written (Task 1's verify was tsc-only; Task 2's verify included vitest). No coverage gap — the full suite passes and covers real behavior, not a rubber-stamp.
|
||||
|
||||
None of these deviations reduce scope or introduce risk beyond what's already accepted in the phase's own threat model.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. This phase is a pure backend library + orchestration service with no UI, no new API route, and no external-service live-credential dependency (Autotask/B2/Postgres are all mocked in tests; the live on-demand trigger route is explicitly deferred to Phase 18). All success criteria are mechanically verifiable via source review + live test execution, both performed above.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All 3 requirements (EVID-02, EVID-03, EVID-04) are implemented, wired end-to-end from the pure parser (16-01) through supporting infrastructure (16-02) to the orchestration service (16-03), and covered by tests that exercise real parsing logic (not mocked-to-pass tautologies) with mocks confined to true I/O boundaries (Postgres, Autotask HTTP, B2 HTTP). Live verification (not just SUMMARY.md narrative) confirms: 49/49 phase-specific tests pass, `tsc --noEmit` is clean, the dev-DB migration is actually applied, and the pre-existing unrelated `itglue-search.test.ts` failures are correctly out of scope (confirmed via git history predating this phase).
|
||||
|
||||
---
|
||||
_Verified: 2026-07-15T14:44:12Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
phase: 17-mimecast-blast-radius-lookup
|
||||
verified: 2026-07-15T14:35:00Z
|
||||
status: passed
|
||||
score: 5/5 must-haves verified
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Phase 17: Mimecast Blast Radius Lookup Verification Report
|
||||
|
||||
**Phase Goal:** Pulse can ask "how far did this message spread" via a Mimecast blast-radius abstraction when Mimecast is configured, and gets a clean `unavailable` signal — never a crash or a block — when it isn't.
|
||||
**Verified:** 2026-07-15T14:35:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | `isMimecastConfigured()` returns true only when both `MIMECAST_CLIENT_ID` and `MIMECAST_CLIENT_SECRET` are set, false otherwise | VERIFIED | `lib/services/mimecast-client.ts:647-649` — `!!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET)`. Tested in `mimecast-client.test.ts` (4 cases: neither/only-ID/only-secret/both), all pass. |
|
||||
| 2 | `getBlastRadius()` returns `status:'unavailable' reason:'not_configured'` synchronously (no Mimecast call) when unconfigured | VERIFIED | `mimecast-blast-radius.ts:92-95` returns before any client construction. Test asserts `getMimecastClientMock`/all 3 fan-out mocks `.not.toHaveBeenCalled()`. |
|
||||
| 3 | `getBlastRadius()` when configured returns normalized matched/delivered/held/rejected/clicked counts + per-recipient status array, built by fanning out to `searchDeliveredMessages` + `getHeldMessages` + `getThreatEvents` UNCONDITIONALLY (not gated behind a `getMessageInfo` miss — corrected D-01 / Pitfall 1) | VERIFIED | `mimecast-blast-radius.ts:104-128` — `getMessageInfo` (line 109-111) is called only for supplementary body/header evidence when `messageId` present, its result is discarded (not awaited into a variable used downstream), and is NOT inside any conditional that gates the `Promise.all([...])` fan-out at line 118, which always runs. Merge logic at 130-184 builds real counts, not fallback zeros. Test "merges delivered/held/threat-event fixtures..." confirms counts (`matched:2, delivered:1, held:1, rejected:1, clicked:1`) and `perRecipient` array. |
|
||||
| 4 | An unexpected error thrown during the fan-out degrades to `status:'unavailable' reason:'lookup_failed'` rather than propagating | VERIFIED | `mimecast-blast-radius.ts:188-194` catch block. Test "degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects" — `getHeldMessagesMock.mockRejectedValue(...)`, asserts resolved (not rejected) result equals `{status:'unavailable', reason:'lookup_failed', error:'Mimecast API timeout'}`. |
|
||||
| 5 | A repeated lookup for the same message identity within the cache TTL returns the cached result without re-calling any MimecastClient method | VERIFIED | `mimecast-blast-radius.ts:101-102` — `getCachedData` checked and returned BEFORE `getMimecastClient()` is called at line 105. Test "returns the cached result on a cache hit..." asserts all 3 fan-out mocks and `setCachedDataMock` not called. |
|
||||
|
||||
**Score:** 5/5 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `lib/services/mimecast-client.ts` | `isMimecastConfigured()` + `_resetMimecastClient()` config gate / test seam, existing exports untouched | VERIFIED | Lines 647-649 (`isMimecastConfigured`), 651-655 (`_resetMimecastClient`) added directly above unmodified `getMimecastClient()` (657-672) and `getMimecastClientForTenant()` (674+). |
|
||||
| `lib/services/mimecast-blast-radius.ts` | `getBlastRadius()` orchestration + `BlastRadiusInput`/`BlastRadiusResult` types | VERIFIED | 195 lines; exports `getBlastRadius`, `BlastRadiusInput`, `BlastRadiusResult` (discriminated union) exactly as specced. |
|
||||
| `lib/services/mimecast-client.test.ts` | Unit tests for `isMimecastConfigured()` + `getMimecastClient()` throw/cache behavior | VERIFIED | 63 lines, 7 tests, all pass (`npx vitest run` confirmed). |
|
||||
| `lib/services/mimecast-blast-radius.test.ts` | Unit tests for config gate, fan-out merge, never-throw, cache-hit | VERIFIED | 227 lines, 6 tests, all pass. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|----|--------|---------|
|
||||
| `mimecast-blast-radius.ts` | `mimecast-client.ts` | `import { isMimecastConfigured, getMimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage } from './mimecast-client'` | WIRED | Import present (line 37-42); both interface types confirmed exported from `mimecast-client.ts` (lines 52, 76, 92 — `MimecastThreatEvent`, `MimecastHeldMessage`, `MimecastDeliveredMessage`). |
|
||||
| `mimecast-blast-radius.ts` | `redis-client.ts` | `import { getCachedData, setCachedData } from './redis-client'` | WIRED | Import present (line 43); both functions called and awaited correctly (cache-check before client construction, cache-write after successful merge only). |
|
||||
|
||||
### D-05 Multi-Tenant Comment Verification
|
||||
|
||||
`grep -qi "D-05" lib/services/mimecast-blast-radius.ts` → present. The module doc-comment (lines 25-34) explicitly states: "KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the single global env-var-configured getMimecastClient(), NOT the per-company `mimecast_tenants` table / getMimecastClientForTenant(). Reports belonging to companies with their own registered Mimecast tenant ... will return `status: 'unavailable'`..." This is a genuine code comment in the shipped file, not just a plan/summary claim.
|
||||
|
||||
### Pitfall-Avoidance Verification (17-RESEARCH.md's 3 documented pitfalls)
|
||||
|
||||
| Pitfall | Research Concern | Shipped-Code Verification |
|
||||
|---------|------------------|---------------------------|
|
||||
| #1 — `getMessageInfo()` has no status/counts; fan-out must be unconditional, not gated on a miss | Implementer might skip fan-out when `getMessageInfo` hits | Confirmed avoided: `getMessageInfo` call (line 110) is a bare `await` whose return value is discarded; the `Promise.all` fan-out (lines 118-128) is unconditional — no `if` branch separates "exact match" from "fallback." Code comment at line 107-108 explicitly documents why. |
|
||||
| #2 — `getThreatEvents()` click derivation is best-effort, not confirmed-zero | Risk of overstating confidence in `clicked: 0` | Confirmed avoided: doc-comment (b) at lines 17-23 states `clicked: 0` means "no click-type threat event found... NOT confirmed zero clicks." `isClickEvent()` helper (line 88-90) comment references D-02 explicitly. |
|
||||
| #3 — Multi-tenant gap silently missed | Risk of a silent single-tenant assumption | Confirmed avoided: D-05 comment present and specific (see above); T-17-03 in the plan's threat model documents the accepted risk; not touched by this phase per explicit scope. |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|----------|
|
||||
| BLAST-01 | 17-01-PLAN.md | Query blast-radius abstraction for delivery data when configured, keyed on message ID/sender/recipient/subject/date-window | SATISFIED | `getBlastRadius()` implemented and tested per Truth #3 above; ROADMAP.md and REQUIREMENTS.md both mark BLAST-01 `[x]`/`Complete`. |
|
||||
| BLAST-02 | 17-01-PLAN.md | Not configured → `status: unavailable`, never blocks; unexpected error also degrades | SATISFIED | Truths #2 and #4 above; both paths tested with explicit call-count/rejection assertions. |
|
||||
|
||||
No orphaned requirements found for Phase 17 in REQUIREMENTS.md (only BLAST-01/BLAST-02 map to this phase).
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None. Scanned all 4 phase-modified/created files (`mimecast-client.ts` diff region, `mimecast-blast-radius.ts`, `mimecast-blast-radius.test.ts`, `mimecast-client.test.ts`) for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|placeholder|not yet implemented` — zero matches.
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| New unit tests pass in isolation | `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` | 2 files, 13 tests, all passed | PASS |
|
||||
| Type-check clean | `npx tsc --noEmit --pretty` | No output / exit 0 | PASS |
|
||||
| Full suite has no new regressions | `npm test` | 27 passed / 1 failed file (`itglue-search.test.ts`, 2 tests) — confirmed pre-existing via `git log` (`a0a6e7f`, predates this phase's commits `8b032c3`/`efbc437`) and unrelated to any file this phase touched | PASS (pre-existing failure correctly excluded per task instructions) |
|
||||
|
||||
### Test Isolation / Mocking Verification
|
||||
|
||||
Both test files declare `vi.mock('./mimecast-client', ...)` / `vi.mock('./redis-client', ...)` (blast-radius test) and import the real `mimecast-client.ts` module only for the config-gate/factory tests (which intentionally exercise the real singleton with env-var manipulation and `_resetMimecastClient()` — no network egress since `getMimecastClient()` only constructs the object, doesn't call out). No `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET` real credentials are referenced; no live HTTP calls are made in either test file (confirmed by reading both files in full — no `fetch`/`request`/network imports present). Redis is fully mocked (`getCachedDataMock`/`setCachedDataMock`).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. This phase produces no UI, no HTTP route, and no externally-observable runtime behavior beyond the unit-testable function contract — all Success Criteria are objectively verifiable via code + tests.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All 5 derived truths verified, both roadmap Success Criteria requirements (BLAST-01, BLAST-02) satisfied, all 3 documented research pitfalls confirmed avoided in the shipped code (not just claimed in SUMMARY.md), D-05 comment confirmed present in the actual file, cache-short-circuit-before-client-call confirmed via call-count assertions, and the full test suite has zero new regressions (the 2 failing `itglue-search.test.ts` tests are confirmed pre-existing and unrelated).
|
||||
|
||||
---
|
||||
|
||||
*Verified: 2026-07-15T14:35:00Z*
|
||||
*Verifier: Claude (gsd-verifier)*
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
phase: 20-remediation-approval-audit-safety
|
||||
verified: 2026-07-16T11:00:00Z
|
||||
status: passed
|
||||
score: 6/6 must-haves verified
|
||||
overrides_applied: 0
|
||||
---
|
||||
|
||||
# Phase 20: Remediation Approval & Audit Safety Verification Report
|
||||
|
||||
**Phase Goal:** Deliver a remediation/approval/audit-safety layer where recommended
|
||||
remediation actions are proposed-only until an operator explicitly approves them,
|
||||
remediation execution is idempotent and gated, false-positive marking is
|
||||
conflict-guarded, and every state-changing action (classify/approve/remediate/
|
||||
mark-false-positive) is audit-logged and permission-gated.
|
||||
|
||||
**Verified:** 2026-07-16T11:00:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | REMED-01: Recommended actions are `proposed`, never auto-executed | VERIFIED | `lib/services/campaign-classifier.ts` has no INSERT into `remediation_actions`; `remediation_actions.status` defaults to `'proposed'` in migration 097; only `approveRemediationActions` creates `'approved'` rows, only on explicit operator call. |
|
||||
| 2 | REMED-02: Operator can approve via `POST /approve`, recording approver, timestamp, exact params | VERIFIED | `app/api/phishing/campaigns/[id]/approve/route.ts` gates `requirePermission('phishing','approve')`, captures actor from `session.user.email` (never request body), delegates to `approveRemediationActions` which inserts `approved_by=actor, approved_at=NOW(), params=$3::jsonb` per action, validated against latest `classifications.recommended_actions`. Unit tests confirm reject-if-not-recommended and reject-if-no-classification. |
|
||||
| 3 | REMED-03: `POST /remediate` proceeds only for approved actions, non-destructive-by-default (simulated), never silently succeeds | VERIFIED | `remediateApprovedActions` throws `RemediationValidationError` when zero `remediation_actions` rows exist (route maps to 400). Only `status='approved'` rows transition; effect is simulated (`UPDATE ... status='completed'`, no external provider call per D-01, confirmed via grep — 0 matches for `not_implemented` outside comments and no HTTP/client calls to Mimecast/other providers in this file). |
|
||||
| 4 | REMED-04: Re-running remediation is idempotent | VERIFIED | `remediateApprovedActions` selects `FOR UPDATE` and only acts on `status='approved'` rows; already-`completed` rows are skipped (no UPDATE, no audit write). Test `is idempotent: a second call transitions nothing and writes no second audit row` asserts 0 additional UPDATE calls and audit-write count stays at 2 across two calls. |
|
||||
| 5 | REMED-05: Operator can mark false positive via API, blocked when remediation approved/completed exists | VERIFIED | `mark-false-positive/route.ts` gated `requirePermission('phishing','approve')`, delegates to `markCampaignFalsePositive`, which runs a `SELECT ... WHERE status IN ('approved','completed') FOR UPDATE` guard before any write; a hit throws `RemediationConflictError` -> route returns 409. Test confirms guard trips and confirms happy-path sets `campaigns.status='false_positive'`. |
|
||||
| 6 | REMED-06: Every state-changing action (classify/approve/remediate/mark-false-positive) writes an audit event | VERIFIED | `writeAuditEvent` is the single INSERT path into `audit_events` (grep confirms no other file inserts into this table). All three remediation-service functions call it inside the same `postgresClient.transaction` as their state write (atomic — rollback discards both). `classify/route.ts` was edited to call `writeAuditEvent({..., eventType:'campaign_classified'})` after `classifyCampaign()` succeeds, closing the fourth action. |
|
||||
|
||||
**Score:** 6/6 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `lib/services/phishing-audit.ts` | `writeAuditEvent(input, client?)` single audit insert path | VERIFIED | 55 lines, parameterized INSERT (`$1`-`$4`), returns `RETURNING id::text`, accepts optional transaction client. |
|
||||
| `lib/services/remediation-service.ts` | approve/remediate/mark-false-positive + typed errors | VERIFIED | 279 lines. Exports `approveRemediationActions`, `remediateApprovedActions`, `markCampaignFalsePositive`, `RemediationValidationError`, `RemediationConflictError` — all present and match plan signatures. |
|
||||
| `lib/services/remediation-service.test.ts` | idempotency/audit/D-04/recommended-only proofs | VERIFIED | 259 lines, 8 real behavioral test cases (not tautological — fake transaction client records actual SQL calls and asserts on them). |
|
||||
| `lib/services/phishing-audit.test.ts` | writer proof | VERIFIED | 3 tests: standalone insert, injected-client routing. |
|
||||
| `lib/permissions.ts` | grants approve+remediate to admin/super-admin only | VERIFIED | `superAdminRole` and `adminRole` both `phishing: ["read","analyze","approve","remediate"]`; `userRole` remains `phishing: ["read"]`. `hasPermission()` enforces this at runtime (not just declared) — verified by reading `lib/auth-utils.ts` `requirePermission()` which calls `hasPermission(userRole, resource, action)` and returns 403 on failure. |
|
||||
| `app/api/phishing/campaigns/[id]/approve/route.ts` | POST approve endpoint | VERIFIED | 77 lines. Permission gate, UUID guard, JSON body validation, actor from session, error mapping (400/409/500), delegates to service. |
|
||||
| `app/api/phishing/campaigns/[id]/remediate/route.ts` | POST remediate endpoint | VERIFIED | 64 lines. Same shell, `phishing:remediate` gate, no body required. |
|
||||
| `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` | POST mark-false-positive endpoint | VERIFIED | 76 lines. `phishing:approve` gate, optional-body tolerant JSON parse, 409 on conflict. |
|
||||
| `app/api/phishing/campaigns/[id]/classify/route.ts` | audit event wiring | VERIFIED | Edited to destructure `session`, import `writeAuditEvent`, call it post-classify with `eventType:'campaign_classified'`. |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `remediation-service.ts` | `audit_events` | `writeAuditEvent(client)` inside `postgresClient.transaction` | WIRED | All three functions call `writeAuditEvent(..., client)` using the same transaction client as their state write — atomic. |
|
||||
| `remediation-service.ts` | `classifications.recommended_actions` | latest-classification read validates approvable types | WIRED | `approveRemediationActions` selects `ORDER BY created_at DESC LIMIT 1` and rejects non-recommended action types before any insert. |
|
||||
| `remediation-service.ts` | `remediation_actions.status` | `FOR UPDATE` status filter drives idempotent completion | WIRED | Confirmed via grep + test (`FOR UPDATE` present in both `remediateApprovedActions` and the D-04 guard in `markCampaignFalsePositive`). |
|
||||
| `approve/route.ts` | `approveRemediationActions` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Route imports and calls the function directly with `(id, actions, actor)`; not a stub — errors from the service propagate to typed HTTP status mapping. |
|
||||
| `remediate/route.ts` | `remediateApprovedActions` | `requirePermission('phishing','remediate')` -> service delegation | WIRED | Same pattern confirmed. |
|
||||
| `mark-false-positive/route.ts` | `markCampaignFalsePositive` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Same pattern confirmed. |
|
||||
| `classify/route.ts` | `writeAuditEvent` | post-classify audit write | WIRED | Call is inside the existing try block after `classifyCampaign(id)` succeeds. |
|
||||
| routes | `middleware.ts` | auth gate | WIRED | `/api/phishing/*` is NOT in `middleware.ts`'s public-route allowlist (confirmed via grep — zero matches), so unauthenticated requests are redirected/blocked before reaching route-level `requirePermission`. |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Service unit tests (11 assertions across approve/remediate/mark-fp/audit-writer) | `npx vitest run lib/services/phishing-audit.test.ts lib/services/remediation-service.test.ts` | 2 files, 11 tests, all passed | PASS |
|
||||
| Type check | `npx tsc --noEmit --pretty` | clean, no output | PASS |
|
||||
| Full test suite regression | `npx vitest run` | 369/371 passing; 2 failures isolated to `lib/services/analyzer/itglue-search.test.ts` | PASS (pre-existing, out-of-scope failure confirmed — last touched by commit `8f8b5ab`, an unrelated earlier commit, not part of this phase's diff) |
|
||||
| No auto-execution of remediation on classify | `grep -n "remediation_actions" lib/services/campaign-classifier.ts` | no matches | PASS |
|
||||
| Single audit insert path | `grep -rn "INSERT INTO audit_events"` across `lib/` and `app/` | only in `phishing-audit.ts` | PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| REMED-01 | 20-01 | Proposed-only, never auto-executed | SATISFIED | See Truth 1 |
|
||||
| REMED-02 | 20-01, 20-02 | Approve via API, records approver/timestamp/params | SATISFIED | See Truth 2 |
|
||||
| REMED-03 | 20-01, 20-02 | Remediate only proceeds for approved, non-destructive, never silent success | SATISFIED | See Truth 3 |
|
||||
| REMED-04 | 20-01 | Idempotent re-run | SATISFIED | See Truth 4 |
|
||||
| REMED-05 | 20-01, 20-02 | Mark false positive via API | SATISFIED | See Truth 5 |
|
||||
| REMED-06 | 20-01, 20-02 | Every state-changing action audited | SATISFIED | See Truth 6 |
|
||||
|
||||
Note: `.planning/REQUIREMENTS.md` still lists REMED-01..06 as "Pending" in its coverage table (lines 198-203) and unchecked (`[ ]`) in the requirement list — this is a tracking-document staleness issue, not a code gap. Recommend updating REQUIREMENTS.md status table as a follow-up, but it does not block phase goal achievement since the underlying code is verified.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
None found in the phase's modified/created files. No `TODO`/`FIXME`/`HACK`/`PLACEHOLDER` markers, no empty handlers, no hardcoded empty returns feeding into rendering, no `not_implemented` code paths (only descriptive prose in comments, explicitly called out in 20-01-SUMMARY.md as a deliberate wording fix to avoid tripping the D-01 grep check).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. This phase is service + API layer only (no new UI in this phase — Phase 22 covers the approval UI). All behaviors are verifiable via code inspection, unit tests, and static grep checks.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All six REMED requirements are backed by real, non-stub implementations:
|
||||
- The service layer (`phishing-audit.ts`, `remediation-service.ts`) performs real parameterized SQL, real transactional atomicity between state writes and audit writes, and real validation logic (not pass-through stubs).
|
||||
- The route layer enforces real permission checks (`requirePermission` calls `hasPermission` against actual role definitions, returning 403 on failure — not just declaring permissions without enforcing them).
|
||||
- Idempotency and the D-04 conflict guard are proven by unit tests that assert on actual SQL call counts and audit-write counts, not just "does not throw."
|
||||
- Actor identity is derived from the server session in all three new routes and in the classify route edit — never from request body, closing the spoofing/repudiation threat noted in the phase's own threat model.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-07-16T11:00:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
364
.planning/phases/21-autotask-triage-note/21-PATTERNS.md
Normal file
364
.planning/phases/21-autotask-triage-note/21-PATTERNS.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
# Phase 21: Autotask Triage Note - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-16
|
||||
**Files analyzed:** 2 new (route + service), 1 optional (service test)
|
||||
**Analogs found:** 2 / 2 (both exact/near-exact structural matches)
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `app/api/phishing/campaigns/[id]/triage-note/route.ts` (new) | controller (route) | request-response | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact |
|
||||
| `lib/services/triage-note-service.ts` (new — planner may name differently) | service | CRUD (read-many) + file/external-write (Autotask `TicketNotes` POST per linked ticket) | `lib/services/campaign-classifier.ts` (evidence gathering half) + `lib/services/workflow-engine.ts` `runAiTroubleshooting` (Autotask write half) | role-match (composite — no single existing file does both halves) |
|
||||
| `lib/services/triage-note-service.test.ts` (optional, if planner follows sibling-test convention) | test | n/a | `lib/services/remediation-service.test.ts` / `lib/services/campaign-classifier.test.ts` | role-match |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `app/api/phishing/campaigns/[id]/triage-note/route.ts` (controller, request-response)
|
||||
|
||||
**Analog:** `app/api/phishing/campaigns/[id]/classify/route.ts` (full file read — 67 lines)
|
||||
|
||||
This is a near-identical structural twin. Copy the whole shape: UUID guard,
|
||||
`requirePermission`, campaign-exists pre-check, service delegation, try/catch
|
||||
with typed error branches, `console.error` with a `[PHISHING-*]` tag.
|
||||
|
||||
**Imports pattern** (lines 12-16):
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requirePermission } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { classifyCampaign } from '@/lib/services/campaign-classifier';
|
||||
import { writeAuditEvent } from '@/lib/services/phishing-audit';
|
||||
```
|
||||
For the new route, swap the service import for the new triage-note service
|
||||
export (e.g. `generateAndPostTriageNote`) — `writeAuditEvent` is optional here
|
||||
(no explicit audit event is required by CONTEXT.md D-01..D-06 for this phase;
|
||||
if the planner wants one, `remediation-service.ts`'s in-transaction audit
|
||||
pattern is the reference — see Shared Patterns below).
|
||||
|
||||
**UUID guard + permission gate** (lines 18, 20-32):
|
||||
```typescript
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { session, error } = await requirePermission('phishing', 'analyze');
|
||||
if (error) return error;
|
||||
|
||||
const { id } = await params;
|
||||
// V5: validate UUID shape before querying — a malformed id would otherwise
|
||||
// surface as an unhandled Postgres error -> uncaught 500.
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
|
||||
}
|
||||
```
|
||||
Per CONTEXT.md's deferred discretion note, `'analyze'` (not `'approve'`) is
|
||||
the recommended permission tier — matches `classify`'s tier since this is
|
||||
informational, not a destructive state change.
|
||||
|
||||
**Campaign-exists pre-check + service delegation + response** (lines 34-43, 58):
|
||||
```typescript
|
||||
try {
|
||||
const campaignRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id FROM campaigns WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!campaignRes.rows[0]) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await classifyCampaign(id); // -> generateAndPostTriageNote(id)
|
||||
|
||||
return NextResponse.json(result);
|
||||
```
|
||||
D-06's response shape (note text + per-ticket `posted`/error status list)
|
||||
should be returned directly as the service's return value — no reshaping
|
||||
needed in the route, matching how `classify`/`approve`/`remediate` all just
|
||||
`NextResponse.json(result)` the service's return type verbatim.
|
||||
|
||||
**Error handling pattern** (lines 59-65 — the ONLY error branch this route
|
||||
needs, since D-05 says individual write failures are captured *inside* the
|
||||
service's return value, not thrown):
|
||||
```typescript
|
||||
} catch (err) {
|
||||
console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
Rename the log tag (e.g. `[PHISHING-TRIAGE-NOTE]`) and message. This catch
|
||||
block should only ever fire for a whole-request failure (e.g. campaign
|
||||
evidence-gathering itself throws) — NOT for a single ticket's Autotask write
|
||||
failing, which D-05/D-06 require to be caught per-ticket inside the service
|
||||
and reported in the 200 response body instead.
|
||||
|
||||
**Optional: typed-error branches** if the service throws domain errors (see
|
||||
`approve/route.ts` lines 63-69 for the pattern, not strictly needed here since
|
||||
this phase has no validation-conflict states like approve/remediate do):
|
||||
```typescript
|
||||
if (err instanceof RemediationValidationError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `lib/services/triage-note-service.ts` (service, CRUD-read + external-write)
|
||||
|
||||
No single existing file does both halves this service needs, so it composes
|
||||
two analogs: **evidence gathering** (read side, copy shape from
|
||||
`campaign-classifier.ts`'s `gatherCampaignEvidence`) and **Autotask note
|
||||
write** (write side, copy verbatim from `workflow-engine.ts`'s
|
||||
`runAiTroubleshooting`).
|
||||
|
||||
**Imports pattern** — composite of `campaign-classifier.ts` (lines 17-19) and
|
||||
the Autotask factory used across `workflow-engine.ts`:
|
||||
```typescript
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
|
||||
import { getAutotaskClient } from './autotask-factory';
|
||||
import type { TicketNote } from '@/lib/types/autotask';
|
||||
```
|
||||
|
||||
**Read-side pattern — bulk-fetch linked reports, then classification +
|
||||
remediation state** (`campaign-classifier.ts` lines 270-296, adapted; also see
|
||||
`app/api/phishing/campaigns/[id]/route.ts` lines 84-96 for the same
|
||||
`reports WHERE campaign_id = $1 ORDER BY created_at ASC` bulk-fetch shape used
|
||||
a third time in this codebase):
|
||||
```typescript
|
||||
const reportsRes = await postgresClient.query<ReportDbRow>(
|
||||
`SELECT r.id::text AS id, r.ticket_id::text AS ticket_id, r.ticket_number,
|
||||
r.title, r.created_at::text AS created_at,
|
||||
c.email_address AS requester_email
|
||||
FROM reports r
|
||||
LEFT JOIN contacts c ON c.id = r.requester_contact_id
|
||||
WHERE r.campaign_id = $1
|
||||
ORDER BY r.created_at ASC`,
|
||||
[campaignId]
|
||||
);
|
||||
```
|
||||
|
||||
**Most-recent classification** (`remediation-service.ts` lines 89-96 — same
|
||||
`ORDER BY created_at DESC LIMIT 1` idiom used for "current" state per D-04):
|
||||
```typescript
|
||||
const classificationRes = await client.query<ClassificationRow>(
|
||||
`SELECT recommended_actions
|
||||
FROM classifications
|
||||
WHERE campaign_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[campaignId]
|
||||
);
|
||||
```
|
||||
For the triage note, select the full row (`verdict, confidence, summary,
|
||||
reasons, recommended_actions, requires_approval, created_at`), not just
|
||||
`recommended_actions` — D-04 requires verdict/confidence/summary/reasons in
|
||||
the note.
|
||||
|
||||
**Current remediation_actions state** (D-04 — "proposed only if no operator
|
||||
has acted, else approved/completed rows") — same table/columns
|
||||
`remediation-service.ts` already reads/writes (lines 133-148, 175-179):
|
||||
```typescript
|
||||
const remediationRes = await postgresClient.query<RemediationActionRow>(
|
||||
`SELECT id::text, action_type, status, approved_by, approved_at::text
|
||||
FROM remediation_actions
|
||||
WHERE campaign_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[campaignId]
|
||||
);
|
||||
```
|
||||
|
||||
**Blast radius — reuse the exact `getBlastRadius()` call shape** from
|
||||
`campaign-classifier.ts` lines 332-351 (D-04/Claude's-Discretion: planner may
|
||||
call fresh or reuse Phase 19's persisted `reasons` — either way this is the
|
||||
call signature to copy if calling fresh):
|
||||
```typescript
|
||||
const blastRadius = await getBlastRadius({
|
||||
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
// BlastRadiusResult is a discriminated union — status: 'ok' | 'unavailable'.
|
||||
// D-04 requires an explicit "unavailable" string in the note when this
|
||||
// branch is hit, never a silent omission.
|
||||
```
|
||||
|
||||
**Write-side pattern — one `createEntity('TicketNotes', ...)` call per linked
|
||||
ticket, copied verbatim from `workflow-engine.ts` lines 581-589**:
|
||||
```typescript
|
||||
const client = getAutotaskClient();
|
||||
await client.createEntity('TicketNotes', {
|
||||
ticketID: ticket.id, // -> report.ticketId for each linked report (D-01)
|
||||
title: 'Troubleshooting Steps (Auto-Generated)', // -> e.g. 'Phishing Triage Summary'
|
||||
description: steps, // -> the generated sanitized note text
|
||||
noteType: 1, // Internal
|
||||
publish: 1,
|
||||
});
|
||||
```
|
||||
`TicketNote` interface for reference (`lib/types/autotask.ts` lines 185-196):
|
||||
```typescript
|
||||
export interface TicketNote {
|
||||
id: number;
|
||||
ticketID: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
noteType?: number;
|
||||
publish?: number;
|
||||
creatorResourceID?: number;
|
||||
creatorType?: number;
|
||||
lastActivityDate?: string;
|
||||
createDateTime?: string;
|
||||
}
|
||||
```
|
||||
`createEntity<T>` generic signature (`lib/services/autotask-client.ts` lines
|
||||
175-189) — throws `Error('Failed to create entity')` if Autotask's response
|
||||
has no `item`, and lets network/HTTP errors from `makeApiCall` propagate
|
||||
uncaught. **This is exactly the failure mode D-05 requires the service to
|
||||
catch per-ticket** — wrap each `createEntity` call in its own try/catch inside
|
||||
a loop over linked tickets, not one try/catch around the whole loop:
|
||||
```typescript
|
||||
const ticketResults: Array<{ ticketId: string; posted: boolean; error?: string }> = [];
|
||||
for (const report of reports) {
|
||||
try {
|
||||
await client.createEntity('TicketNotes', {
|
||||
ticketID: Number(report.ticketId),
|
||||
title: 'Phishing Triage Summary',
|
||||
description: noteText,
|
||||
noteType: 1,
|
||||
publish: 1,
|
||||
});
|
||||
ticketResults.push({ ticketId: report.ticketId, posted: true });
|
||||
} catch (err) {
|
||||
console.error('[TRIAGE-NOTE] Failed to post note to ticket', report.ticketId, err);
|
||||
ticketResults.push({
|
||||
ticketId: report.ticketId,
|
||||
posted: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Sanitization precedent** — `lib/services/analyzer/itglue-redact.ts` (full
|
||||
file, 65 lines) is the spirit-reference named in CONTEXT.md, though it
|
||||
redacts by KEY NAME across an arbitrary object tree (IT Glue documents), which
|
||||
doesn't map directly onto this phase's need (truncating URL query strings
|
||||
inside plain prose text). Two concrete things to actually copy:
|
||||
1. The **module-level "why" comment convention** — state plainly what must
|
||||
never leak and why, mirroring lines 1-16 of `itglue-redact.ts`.
|
||||
2. The **exported-for-tests + pure-function** shape — a small
|
||||
`sanitizeIndicatorValue(value: string, type: string): string` (or similar)
|
||||
function, unit-testable in isolation, same as `redact()`/`isSensitiveKey()`
|
||||
are exported standalone in `itglue-redact.ts` lines 26-28 and 62-64. For
|
||||
URL truncation specifically there is no existing analog in this codebase —
|
||||
this is genuinely new logic (strip query string via `new URL(value).origin
|
||||
+ new URL(value).pathname`, wrapped in try/catch for malformed URLs).
|
||||
|
||||
**No-op / synthesized-value pattern for missing evidence** — copy
|
||||
`mimecast-blast-radius.ts`'s discriminated union (`status: 'ok' | 'unavailable'`,
|
||||
never a thrown error for a missing/misconfigured integration) as the model for
|
||||
how the note text should render "blast radius data unavailable" rather than
|
||||
omitting the section — same spirit as `campaign-classifier.ts` line 350's
|
||||
`{ status: 'unavailable', reason: 'not_configured' }` synthesis when there are
|
||||
no linked reports at all.
|
||||
|
||||
---
|
||||
|
||||
### `lib/services/triage-note-service.test.ts` (test, optional)
|
||||
|
||||
**Analog:** `lib/services/remediation-service.test.ts` and
|
||||
`lib/services/campaign-classifier.test.ts` (not read in full — file names
|
||||
only, per early-stopping guidance; both are existing Vitest suites under
|
||||
`lib/services/` that test service functions directly against a real/fixture
|
||||
Postgres, following the project's stated test coverage: `lib/services/**` is
|
||||
covered). Structure to follow: mock or seed `campaigns`/`reports`/
|
||||
`classifications`/`remediation_actions` rows, mock `getAutotaskClient()` (or
|
||||
the whole `autotask-factory` module) to assert `createEntity` was called once
|
||||
per linked ticket with the expected `ticketID`/`description`, and assert the
|
||||
per-ticket failure path (D-05/D-06) when a mocked `createEntity` rejects for
|
||||
one of several tickets.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Auth/Permission gate
|
||||
**Source:** `lib/auth-utils.ts` lines 51-71 (`requirePermission`), used
|
||||
identically by `classify/route.ts` line 24, `approve/route.ts` line 28,
|
||||
`remediate/route.ts` line 27, `route.ts` (GET) line 62.
|
||||
**Apply to:** the new triage-note route.
|
||||
```typescript
|
||||
const { session, error } = await requirePermission('phishing', 'analyze');
|
||||
if (error) return error;
|
||||
```
|
||||
`lib/permissions.ts` line 33/50/64/78 confirms `'analyze'` is already granted
|
||||
to admin/super-admin/user roles (only the read-only-ish role at line 78 lacks
|
||||
it) — no new permission statement needed.
|
||||
|
||||
### UUID param validation
|
||||
**Source:** identical `UUID_RE` regex + early-400 pattern in all four existing
|
||||
`campaigns/[id]/*` routes (`classify`, `approve`, `remediate`, base `route.ts`).
|
||||
**Apply to:** the new triage-note route — copy the exact regex, don't
|
||||
re-derive it.
|
||||
|
||||
### Campaign-exists pre-check before service delegation
|
||||
**Source:** `classify/route.ts` lines 34-41, `approve/route.ts` lines 52-59,
|
||||
`remediate/route.ts` lines 40-46 — all three query `SELECT id FROM campaigns
|
||||
WHERE id = $1` and return 404 before calling their service function.
|
||||
**Apply to:** the new triage-note route, same shape.
|
||||
|
||||
### Error response shape
|
||||
**Source:** every phishing route's catch block:
|
||||
`NextResponse.json({ error: '...', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 })`
|
||||
with a `console.error('[PHISHING-<ACTION>] ...')` line immediately before.
|
||||
**Apply to:** the new route's outer catch (whole-request failures only — see
|
||||
D-05 note above about per-ticket failures NOT using this branch).
|
||||
|
||||
### Safe Autotask ticket-note write
|
||||
**Source:** `lib/services/workflow-engine.ts` lines 581-589
|
||||
(`runAiTroubleshooting`), backed by `lib/services/autotask-client.ts`
|
||||
`createEntity<T>` (lines 175-189) and `lib/services/autotask-factory.ts`
|
||||
`getAutotaskClient()`.
|
||||
**Apply to:** the new service's write loop — `noteType: 1` (Internal),
|
||||
`publish: 1` (All Autotask Users, still non-portal per
|
||||
`AUTOTASK_API_GUIDE.md` line 365) are the existing codebase's only precedent
|
||||
values; reuse them unless the planner has a specific reason to pick
|
||||
`publish: 2` (Internal Users Only — even more restrictive, also non-portal).
|
||||
|
||||
### Audit trail (optional — not required by CONTEXT.md for this phase)
|
||||
**Source:** `lib/services/phishing-audit.ts` (full file, 55 lines) —
|
||||
`writeAuditEvent({ campaignId, actor, eventType, payload }, client?)`. Used by
|
||||
every state-*changing* action (classify/approve/remediate/false-positive).
|
||||
This phase is read+external-write, not a Postgres state change, so an audit
|
||||
row is NOT strictly required by any D-0x decision — CONTEXT.md's Deferred
|
||||
Ideas section explicitly puts "persisting sent-note history" out of scope.
|
||||
If the planner still wants a lightweight audit trail of *when* a triage note
|
||||
was requested (not full content), this is the write shape to reuse; `client`
|
||||
param is optional so it can be called standalone (no transaction needed since
|
||||
there's no corresponding state row to keep atomic with).
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| URL/text sanitization helper (e.g. `lib/services/triage-note-sanitize.ts`, if split out) | utility | transform | No existing codebase function truncates URL query strings or formats human-readable prose from structured evidence — `itglue-redact.ts` redacts by object key name (a different technique for a different data shape); this is genuinely new logic per CONTEXT.md's "Claude's Discretion" section. |
|
||||
| Note-text template/formatter | utility | transform | No existing "build human-readable prose from campaign+classification+remediation rows" function exists anywhere in the codebase — closest precedent is `campaign-classifier.ts`'s one-line `summary` string (line 487), which is far shorter than what D-04 requires here. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `app/api/phishing/**`, `lib/services/campaign-classifier.ts`,
|
||||
`lib/services/remediation-service.ts`, `lib/services/campaign-grouping-service.ts`,
|
||||
`lib/services/phishing-audit.ts`, `lib/services/workflow-engine.ts`,
|
||||
`lib/services/autotask-client.ts`, `lib/services/autotask-factory.ts`,
|
||||
`lib/services/mimecast-blast-radius.ts`, `lib/services/analyzer/itglue-search.ts`,
|
||||
`lib/services/analyzer/itglue-redact.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`,
|
||||
`lib/types/autotask.ts`, `migrations/097_phishing_triage_schema.sql`.
|
||||
**Files scanned:** 15
|
||||
**Pattern extraction date:** 2026-07-16
|
||||
Loading…
Add table
Add a link
Reference in a new issue