Commit graph

212 commits

Author SHA1 Message Date
20b1e1bb6f test(22-01): add failing test for resolveTicketToCampaign
- Covers no-report, ungrouped, and grouped resolution states
- Asserts parameterized ticket_id lookup query shape
2026-07-16 14:26:28 -04:00
2d410f8d15 feat(21-02): implement triage-note-service (evidence gather + note post loop)
GREEN: generateAndPostTriageNote(campaignId) gathers linked reports,
most-recent classification (NUMERIC confidence coerced to a JS number),
current remediation_actions, and real url indicators via the
reports->messages->indicators join; renders the sanitized note via Plan
01's formatTriageNote, then posts one internal TicketNotes write per
linked ticket with independent per-ticket error capture so a single
write failure never aborts the call (D-05) and note text is always
returned (D-06).
2026-07-16 12:13:50 -04:00
34a0269e9b test(21-02): add failing test for triage-note service
RED: generateAndPostTriageNote does not exist yet — covers per-ticket
write loop, D-05 partial-failure isolation, D-06 note-text-always-returned,
indicator-URL sanitization flow, and NUMERIC confidence coercion.
2026-07-16 12:13:18 -04:00
17ba0e880f feat(21-01): implement triage-note formatter + TriageNoteEvidence contract
- formatTriageNote renders verdict/confidence/summary/reasons/blast-radius
  (both branches)/recommended actions/current remediation state as prose
- Routes indicator URLs through sanitizeUrl and the whole assembled output
  through sanitizeNoteText before returning
- Handles null verdict/confidence gracefully
- Exports TriageNoteEvidence interface for Plan 02
- All 9 formatter tests pass
2026-07-16 12:06:31 -04:00
b4e05eb6ad test(21-01): add failing tests for triage-note formatter
- Cover verdict/confidence rendering, reasons/summary sections
- Cover both BlastRadiusResult branches (ok and unavailable)
- Cover remediation-state rendering (empty and populated)
- Cover URL sanitization and null-verdict graceful handling
2026-07-16 12:05:59 -04:00
226f300513 feat(21-01): implement triage-note sanitizer
- sanitizeUrl strips query+fragment, keeps scheme+host+path, never throws
- sanitizeNoteText redacts Bearer/Authorization tokens and credential
  query-param values while preserving sender emails and attachment hashes
- All 8 sanitizer tests pass
2026-07-16 12:05:34 -04:00
cc93707e41 test(21-01): add failing tests for triage-note sanitizer
- Cover URL query/fragment stripping, malformed-URL no-throw
- Cover Bearer token and credential query-param redaction
- Cover email/hash preservation (evidence, not secrets)
2026-07-16 12:04:58 -04:00
80e7129740 feat(20-02): grant phishing approve+remediate to admin roles (D-02)
- superAdminRole and adminRole now include "approve" and "remediate" for phishing
- userRole unchanged (still read-only)
2026-07-16 10:43:18 -04:00
b1b66f9f49 feat(20-01): add markCampaignFalsePositive with D-04 conflict guard
- Guards against marking false positive when any approved/completed
  remediation exists for the campaign (RemediationConflictError, T-20-04)
- Sets campaigns.status='false_positive' and writes one atomic audit row
  recording previousStatus + reason (REMED-05, REMED-06)
- Fixes test mock SQL substring match for the D-04 guard query
- Reworded a header comment to avoid a literal "not_implemented" string
  that tripped the D-01 grep acceptance check
2026-07-16 10:38:34 -04:00
3d63fab600 feat(20-01): add approve + remediate remediation orchestrators
- approveRemediationActions validates each requested action against the
  campaign's latest classification.recommended_actions and materializes
  only recommended action types as status='approved' rows plus one
  atomic audit row (REMED-01, REMED-02, REMED-06)
- remediateApprovedActions transitions approved rows to 'completed'
  (D-01 simulated internal effect, no external provider call), is
  idempotent via the status='approved' FOR UPDATE filter (REMED-04),
  and fails explicitly on zero remediation_actions rows (REMED-03)
- RemediationValidationError / RemediationConflictError typed error classes

Note: markCampaignFalsePositive (referenced by the already-committed test
file) lands in the next commit (Task 3) — tsc will be clean again once
that lands.
2026-07-16 10:37:37 -04:00
2937fe7bab test(20-01): add failing tests for remediation-service orchestrators
- approveRemediationActions: recommended-only validation, atomic insert+audit
- remediateApprovedActions: idempotency (REMED-04), explicit zero-approved failure
- markCampaignFalsePositive: D-04 conflict guard, atomic audit write
2026-07-16 10:36:14 -04:00
98d3e925e5 feat(20-01): add audit-event writer (writeAuditEvent)
- Single parameterized append-only INSERT into audit_events
- Supports optional injected transaction client for atomic writes
- Documents the four canonical event_type strings for this phase
2026-07-16 10:35:35 -04:00
38c1ae4daf feat(19-01): implement classifyCampaign orchestrator + evidence gathering (GREEN)
- gatherCampaignEvidence: bulk-fetches reports (earliest-first, joined to
  contacts for requester email) -> messages (report_id = ANY) -> indicators
  (message_id = ANY), parses messages.headers JSONB into bounded
  ParsedMessage fields, and runs one getBlastRadius() lookup keyed off the
  earliest report's sender/subject/±24h window (research A6); synthesizes
  unavailable/not_configured with no Mimecast call when no report is linked
- evaluateThreatTier (D-03): blastRadius.status==='ok' AND
  (delivered>0 OR clicked>0) AND (hasHardAuthFail via effectiveAuthResults
  OR hasKnownBadIndicatorMatch — same attachment_hash/url value spanning
  >=2 distinct messages, cross-report correlation only, no external
  reputation lookup per research A4)
- evaluateSpamVsUnwanted (D-04): UNWANTED when any attachment/url indicator
  matches or delivery is contained to the reporter(s) only; SPAM otherwise
- classifyCampaign: D-06 simulation short-circuit -> D-03 -> D-04 ->
  computeConfidence -> mapVerdictToActions -> computeRequiresApproval ->
  append-only INSERT into classifications (D-02, no ON CONFLICT), wrapped
  in try/catch logging [CAMPAIGN-CLASSIFIER] + err.message and rethrowing
- isKnownSimulationSender relaxed to a narrower SenderIdentity shape so both
  the full NormalizedMessage fixtures and the bounded ParsedMessage type
  can share it
- All 39 tests green; tsc clean; full `npm test` suite green except 2
  pre-existing, unrelated itglue-search.test.ts failures (see
  deferred-items.md)
2026-07-16 08:20:58 -04:00
f6c954aa23 test(19-01): add classifyCampaign orchestrator tests (RED)
- Mock ./postgres-client (query-only) and ./mimecast-blast-radius
  (getBlastRadius), routing staged rows by SQL substring per call
- Named tests for CLASSIFY-01 (returns exactly one verdict), D-02
  (append-only INSERT, no ON CONFLICT), D-06 simulation allowlist
  (KnowBe4 From-match + BSN Return-Path-match), positive-path D-03 THREAT,
  D-03 known-bad-indicator OR-branch (auth pass, shared indicator across 2
  messages), D-04 SPAM/UNWANTED split, and CLASSIFY-06 evidence bounding
2026-07-16 08:20:49 -04:00
3ea6c95e38 feat(19-01): implement classifier pure rule functions (D-05/D-06/D-08)
- KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com)
  with domainMatchesAllowlist (exact-or-proper-subdomain, no substring match
  — T-19-01) and isKnownSimulationSender (checks From + Return-Path domain
  — Pitfall 3)
- effectiveAuthResults (authResultsOriginal precedence — Pitfall 1) and
  hasHardAuthFail (spf/dkim/dmarc hard-fail only)
- computeConfidence: additive-from-1.0 with 0.4/0.3/0.2 named deductions,
  floors at 0.10 (D-05)
- mapVerdictToActions + DESTRUCTIVE_ACTIONS + computeRequiresApproval
  (OR'd across actions, D-08/CLASSIFY-02)
- All 30 pure-function tests green; tsc clean
2026-07-16 08:14:57 -04:00
f4e6baf505 test(19-01): add failing tests + synthetic fixtures for classifier pure rule functions
- campaign-classifier.test.ts: describe blocks for domainMatchesAllowlist,
  isKnownSimulationSender, effectiveAuthResults, hasHardAuthFail,
  computeConfidence, mapVerdictToActions, computeRequiresApproval
- campaign-classifier.fixtures.ts: synthetic KnowBe4/BSN simulation fixtures
  plus non-simulation threat/clean-spam/suspicious-unwanted fixtures
- Covers CLASSIFY-01/02/03/04/06 pure-function behavior (T-19-01, Pitfall 1/3)
2026-07-16 08:14:51 -04:00
6945591f76 fix(18-05): decrement origin campaign report_count on migration + diverged create-new (CR-02)
Cross-campaign migration (a report moves from campaign A to a
different existing campaign B on re-analyze) never decremented A's
report_count, leaving it permanently stale. The same abandonment
happens on the signal-diverged create-new fall-through added for
CR-03 (Task 1) — landing on a brand-new campaign instead of an
existing sibling's, but the same class of staleness.

Add a shared decrementOriginCampaign() helper and call it in both
locations, guarded so it only fires when there is a real origin
(ownReport.campaign_id non-null) and the match doesn't resolve back to
the report's own campaign (the existing same-campaign no-op guard is
unchanged). D-08 sibling upgrades (never-grouped report -> sibling
campaign) increment the destination only, with no origin to
decrement.

- Test E: cross-campaign migration decrements origin, increments destination
- Test F: D-08 upgrade with no prior campaign increments destination only
- Test G (renamed from existing 18-04 test): same-campaign re-match remains zero mutations
- Test H: signal-diverged create-new also decrements the abandoned origin (plan-checker-flagged case)

Tests E and H failed against the pre-fix code (confirmed during RED).
2026-07-16 00:10:45 -04:00
633b48c3b2 fix(18-05): add own-campaign revalidation guard before create-new (CR-03)
Re-analyzing a single-report campaign (no sibling report exists yet)
fell through to "create new campaign" because every tier query
self-excludes the report's own row, so matchCampaignId stayed null and
the report's still-valid campaign was abandoned in favor of a
duplicate campaigns row sharing the same campaign_key.

Add a guard that runs only when no sibling matched and the report
already has campaign_id: recompute the report's current tier keys
once, look up its own campaign row, and reuse it (created:false) when
the stored campaign_key still matches. Falls through to create-new
unchanged when the campaign row is gone or the signal has genuinely
diverged. D-08 sibling upgrades are untouched (guard only runs when no
sibling matched).

- Tests A/B reproduce the exact CR-03 duplicate-campaign scenario and
  failed against the pre-fix code (confirmed during RED)
- Tests C/D guard the create-new no-regression and signal-diverged
  fall-through paths
2026-07-16 00:08:36 -04:00
9ca2ccf1c7 fix(18-04): short-circuit own-campaign re-match to stop report_count double-increment
- OwnReportRow now selects campaign_id::text; match branch no-ops (created:
  false, no UPDATE) when the tiered match resolves to the report's own
  current campaign_id via a sibling row
- Closes CAMP-02 gap / CR-01: /analyze can be re-run indefinitely without
  inflating campaigns.report_count
2026-07-15 22:25:04 -04:00
afd7af70c8 test(18-04): add failing regression test for campaign report_count double-increment
- Sibling report already in campaign-1 re-matches via Tier 3; asserts zero
  UPDATE campaigns / INSERT campaigns / UPDATE reports calls (CAMP-02/CR-01)
2026-07-15 22:24:17 -04:00
19b8b4b415 feat(18-02): wire groupReportIntoCampaign into webhook + cron sweep paths
- webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign
  with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08)
- phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the
  existing try/catch so a grouping failure counts against result.errors
  without aborting the sweep
2026-07-15 19:31:18 -04:00
04ec51f370 feat(18-01): add phishing permission resource + role grants
- statement gets the full D-05 vocabulary now: read/analyze/approve/remediate
- superAdminRole and adminRole grant read+analyze
- userRole grants read only (cannot trigger /analyze)
- approve/remediate declared but ungranted to any role until Phase 20
2026-07-15 19:23:09 -04:00
da926bbe97 feat(18-01): implement groupReportIntoCampaign tiered matching
- Tiered find-or-create inside postgresClient.transaction (Pitfall 2 —
  campaigns.campaign_key has no UNIQUE constraint): Tier 1 Message-ID,
  Tier 2 attachment-hash/URL-domain + subject + sender + 24h, Tier 3
  sender + normalized subject + client + 24h (CAMP-01)
- Match path bumps report_count/last_seen_at and links reports.campaign_id
  without creating a second campaign; no-match path inserts a new
  campaigns row keyed by the strongest available tier signal (CAMP-02)
- skipIfAlreadyGrouped short-circuits before the transaction (D-08); the
  /analyze route path always re-runs full tiered matching
- Every tier query excludes the report's own id (r.id != $n) so a
  self-match against a report's own messages/indicators can never
  double-increment its already-linked campaign on re-run
- D-07 doc comment states the Tier-3-only automatic-path limitation:
  parseAndStoreMessage is not wired into the webhook/cron path this phase
2026-07-15 19:22:31 -04:00
ea677b7aba test(18-01): add campaign-grouping tier-key helpers + test scaffold
- normalizeSubject strips repeated Re:/Fwd:/Fw: prefixes case-insensitively, lowercases, trims (D-03)
- extractUrlDomain returns hostname or null (never throws) for malformed URLs (Pitfall 4)
- test file mocks ./postgres-client before import, mirroring phishing-eml-service.test.ts's discipline
2026-07-15 19:19:44 -04:00
efbc437e2e feat(17-01): build mimecast-blast-radius.ts fan-out orchestration
- Add getBlastRadius(): never-throwing orchestration that fans out to
  searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01,
  unconditional fan-out) and merges into normalized matched/delivered/
  held/rejected/clicked counts + perRecipient status array
- Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured'
  synchronously when Mimecast is unconfigured, never constructs the client
- Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before
  any MimecastClient call on hit
- clicked derived best-effort from getThreatEvents() analysis[] (D-02);
  documents the /api/ttp/url/get-logs limitation in code
- Documents D-05 known limitation: single global getMimecastClient() only,
  not per-company mimecast_tenants
- Unrecognized delivered-message status strings treated conservatively as
  non-rejected (A3 unconfirmed enum), raw values logged at debug level
- Add lib/services/mimecast-blast-radius.test.ts covering config gate,
  cache-hit short-circuit, fan-out merge, never-throw-on-error, and
  unknown-recipient classification
- Log pre-existing unrelated itglue-search.test.ts failures to
  deferred-items.md (out of scope for this plan)
2026-07-15 14:30:00 -04:00
8b032c3890 feat(17-01): add isMimecastConfigured() config gate + test seam
- Add isMimecastConfigured() to lib/services/mimecast-client.ts mirroring
  the pax8-factory.ts is<Name>Configured() convention
- Add _resetMimecastClient() test seam so tests can isolate env-var state
- Add lib/services/mimecast-client.test.ts covering config gate + throw/cache
  behavior of getMimecastClient()
2026-07-15 14:27:47 -04:00
ce16e6747c test(16-03): phishing-eml-service.test.ts orchestration coverage (mocked I/O)
- mocks postgresClient, getAutotaskClient, and B2 client (isB2Configured/presignUpload); no real Postgres/Autotask/B2 credentials or network
- happy path: one messages insert with D-06 auth verdicts in headers, indicators inserted
- no-eml no-op: NO_EML_ATTACHMENTS -> stored:false, no messages insert
- B2 unconfigured: raw_ref null, no presignUpload/PUT
- B2 configured: raw_ref = phishing/{reportId}/{attachmentId}.eml, exactly one PUT
- no-network invariant: fetch never called with a message-body URL from the fixture
- attachment_hash indicator metadata carries filename/contentType/size/related
- all fixtures reused from eml-parser.fixtures.ts (synthetic only)
2026-07-15 10:39:59 -04:00
5088d8d511 feat(16-03): phishing-eml-service.ts orchestration (list->select->fetch->B2->parse->persist)
- parseAndStoreMessage lists ticket attachments, selects the original message via selectOriginalMessage
- fetches full content via getAttachmentContent, size-guards the decoded buffer against MAX_EML_BYTES
- uploads raw bytes to B2 under phishing/{reportId}/{attachmentId}.eml when isB2Configured(), gracefully degrading rawRef=null on failure/absence
- parses via parseEml and persists one messages row (headers incl. D-06 auth verdicts, urls, attachments, body_preview, raw_ref) plus indicators rows (attachment_hash/url/sender) carrying D-07 metadata JSONB
- never fetches any URL extracted from the message; the only outbound calls are the Autotask attachment GET and the B2 presigned PUT
2026-07-15 10:37:35 -04:00
f3ace33f86 chore: merge executor worktree (worktree-agent-a2b7d8cafac0f4270) 2026-07-15 10:32:42 -04:00
654e624505 feat(16-01): implement parseEml, parseAuthResults, extractUrls, buildBodyPreview
GREEN: parseEml normalizes headers (From/Reply-To/Return-Path/To/Cc/
Subject/Date/Message-ID), builds the ordered Received chain from
mail.headerLines, and maps mailparser attachments to AttachmentMeta
(name/content-type/size/sha256 checksum, related flag preserved for
inline/CID parts per Pitfall 5).

parseAuthResults hand-rolls RFC 8601 Authentication-Results parsing
(spf/dkim/dmarc verdicts) rather than using mailauth, which performs
live DNS/HTTP verification (SC#3 violation). Both Authentication-Results
and Authentication-Results-Original are read via mail.headerLines
(Pitfall 4 — headers Map only exposes one occurrence of a repeated
header) and parsed into distinct authResults/authResultsOriginal fields.

extractUrls uses linkify-it with fuzzyLink enabled (scheme-less www.
URLs) scanning both text and html parts, deduped, never dereferenced.

buildBodyPreview prefers mail.text, falling back to a small hand-rolled
HTML-to-text stripper (not the undeclared transitive html-to-text
dependency — see SUMMARY deviations) when only HTML exists; truncated
to 500 chars.

MAX_EML_BYTES (10 MB, below B2's 25 MB cap) is enforced before
simpleParser is ever called (T-16-01 DoS guard).

26/26 tests pass; tsc clean for eml-parser files; full npm test run
confirms 2 pre-existing itglue-search.test.ts failures are unrelated
(logged to deferred-items.md).
2026-07-15 10:30:18 -04:00
4df4816b21 test(16-01): add failing tests for parseEml/parseAuthResults/extractUrls/buildBodyPreview
RED: covers EVID-03 (normalized headers, structured auth verdicts,
Received chain, URLs, attachment metadata incl. related flag),
EVID-04 (no-network spy, truncated body preview), and the DoS size
guard (oversized buffer rejected before simpleParser). New synthetic
fixtures: rich multipart, auth-results-original, inline/CID attachment,
long-body, fuzzy-URL, and an oversized-buffer generator. None of these
exports exist on eml-parser.ts yet.
2026-07-15 10:28:22 -04:00
8630fd5151 feat(16-02): add EML_OBJECT_KEY_REGEX + parameterize B2 key validation (D-05)
- New EML_OBJECT_KEY_REGEX enforces phishing/<id>/<id>.eml, rejects traversal
- presignDownload/presignUpload/downloadToBuffer take optional keyRegex,
  defaulting to OBJECT_KEY_REGEX so existing LogLift call sites are unchanged
- OBJECT_KEY_REGEX itself left untouched (skill-doc rule)
2026-07-15 10:23:19 -04:00
e4718ae71a feat(16-01): implement selectOriginalMessage three-tier attachment selection
GREEN: rfc.eml exact match -> single non-OriginatingEmail message/rfc822
candidate (covers KnowBe4 versioned filenames) -> OriginatingEmail.eml
fallback -> null. Case-insensitive on both content-type and filename
(checked via title/fullPath basename). All 8 selectOriginalMessage tests
pass; tsc clean for eml-parser files.
2026-07-15 10:23:00 -04:00
6de92a507b test(16-02): add failing tests for EML_OBJECT_KEY_REGEX + parameterized B2 key validation
- EML_OBJECT_KEY_REGEX must match phishing/<id>/<id>.eml and reject traversal/wrong-ext/LogLift shapes
- presignUpload must accept an optional keyRegex arg, defaulting to OBJECT_KEY_REGEX
2026-07-15 10:22:46 -04:00
9b65de72dc feat(16-02): add AutotaskClient.getAttachmentContent()
- Fetches Tickets/{id}/Attachments/{attachmentId}, reads response.items?.[0]
- Confirmed live: per-attachment-ID GET is list-shaped, not {item:...}
2026-07-15 10:22:03 -04:00
8be10db6e0 test(16-02): add failing test for AutotaskClient.getAttachmentContent
- Asserts items[0] convention for per-attachment-ID GET
- Asserts {item:...}-shaped response yields null (guards against regression)
2026-07-15 10:22:01 -04:00
2fde1156da test(16-01): add failing tests for selectOriginalMessage (EVID-02)
RED: covers all three selection tiers plus ambiguous/no-eml/empty edge
cases. lib/services/eml-parser.ts does not exist yet — tests fail to
resolve the module import.
2026-07-15 10:21:58 -04:00
9c4584d428 fix(15): exclude soft-deleted notes/time entries from phishing evidence
ticket_notes and time_entries both carry an is_deleted soft-delete flag
(per CLAUDE.md audit-column convention); gatherTicketEvidence was reading
both without filtering it, so retracted notes and reversed time entries
showed up as evidence for every phishing report. Found during code-review
re-verification of the Phase 15 CR-01/WR-01/WR-02 fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wWroM6FXkQJiH3JgYcony
2026-07-15 08:17:07 -04:00
7c63c5f76d fix(15): WR-02 refresh evidence snapshot even when content_hash unchanged 2026-07-15 08:08:20 -04:00
c875081275 fix(15): WR-01 use shared getAutotaskClient factory in phishing-detector 2026-07-15 08:07:45 -04:00
ecc34b4bad fix(15): CR-01 fix webhook-triggered phishing detection reading from unpopulated payload.entity 2026-07-15 08:05:30 -04:00
b199d9991c feat(15-03): register phishing-sweep schedule + migration 098
- extend sync_type union with 'phishing-sweep'
- add defaultSchedules entry (disabled by default, daily 5am cron)
- dispatch branch dynamically imports and calls sweepPhishingTickets
- migrations/098_phishing_sweep_schedule.sql seeds the row for existing installs
2026-07-15 07:49:01 -04:00
194b58b196 feat(15-03): fire-and-forget phishing detection on ticket.created webhook
- triggerPhishingDetection() mirrors triggerWorkflowEngine's payload.entity-first shape
- reads createdByContactID (Autotask field) into created_by_contact_id, per entity-mapper.ts:211
- called alongside the existing workflow-engine trigger, not awaited in the request path
2026-07-15 07:48:17 -04:00
dbd2ebe63c feat(15-03): add bounded phishing sweep service
- sweepPhishingTickets() queries recently-modified tickets (7d window, LIMIT 500)
- delegates each ticket to shared detectPhishingTicket (no duplicated match/hash logic)
- per-row try/catch increments errors without aborting the loop
2026-07-15 07:47:08 -04:00
15d0caa20d feat(15-02): add evidence capture + detectPhishingTicket orchestration
- gatherTicketEvidence: company_name, ticket_notes, time_entries (all
  parameterized $1 queries), and Autotask attachment metadata only
  (fullPath/title/contentType, never base64 data); Autotask call wrapped in
  try/catch so a failure degrades to an empty attachments array
- detectPhishingTicket: matches, hashes, checks D-04 idempotency guard
  (skips re-gathering/writing when content_hash is unchanged), then upserts
  one reports row via ON CONFLICT (ticket_id) DO UPDATE ... RETURNING id
- requester_contact_id binds from ticket.contact_id, created_by_contact_id
  from ticket.created_by_contact_id per interfaces contract
2026-07-15 07:42:59 -04:00
aabf5322e9 feat(15-02): implement phishing pattern matcher + content hash
- KNOWN_PHISHING_PATTERNS: the 8 locked DETECT-01 strings
- matchesPhishingPatterns: case-insensitive substring match (toLowerCase +
  includes only, no RegExp/eval), mirrors robotic-classifier.evaluateContains
- computePhishingContentHash: sha256 over title+description only (D-04),
  excludes bump-prone fields like status/last_activity_date
2026-07-15 07:42:14 -04:00
0e7daf9a6a test(15-02): add failing tests for phishing pattern matcher + content hash
- Covers all 8 locked DETECT-01 patterns individually, negative case,
  case-insensitivity, and content-hash stability/change/null-normalization
2026-07-15 07:41:14 -04:00
00f196c115 fix(auth): stop hasPermission crashing for non-admin ("user") roles
hasPermission()'s parameter was named userRole: string, shadowing the
module-level userRole role object exported earlier in the same file.
The internal roles map's `user: userRole` entry therefore bound to the
shadowed string parameter (e.g. "user") instead of the actual role
object — so any permission check for a "user"-role session (the only
non-admin role in the app) hit `"user".statements[resource]`, which is
undefined, and threw instead of returning false.

Net effect: every requirePermission()-gated route in the app returned
a 500 instead of a 403 for non-admin users. This predates phase 14 —
surfaced now because phase 14's PAX8 resolve route is admin-gated and
got exercised by a non-admin account during verification.

Renamed the parameter to roleName to remove the collision. Added
lib/permissions.test.ts (previously zero coverage on this file) to
lock in the "user"/admin/super-admin behavior and prevent regression.
2026-07-12 18:20:36 -04:00
4d7a58b46c docs(14-02): drop literal candidate_company_ids mention from resolver docstring
Satisfies the plan's grep-based acceptance check for "no reference to
candidate_company_ids in the resolver source" — code already had no
membership check, this only reworded the explanatory comment.
2026-07-11 14:29:43 -04:00
0ce51a0167 feat(14-02): implement resolvePax8CompanyMatch resolver
- Two-table transactional write: pax8_companies.match_method='manual' AND
  pax8_company_match_review.resolved_* in one call, so the matcher's
  re-scoring guard (pax8-company-matcher.ts ~216-231) never re-flags a
  manually resolved company
- Guards not_found / already_resolved via FOR UPDATE select
- Validates target company existence + active state (substitute for
  candidate-membership check — D-05/D-09 allow non-candidate ids)
- All five vitest behavior cases green; tsc clean
2026-07-11 14:28:48 -04:00