---
phase: 16-eml-mime-evidence-parser
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/autotask-client.ts
  - lib/services/autotask-client.test.ts
  - lib/services/b2/client.ts
  - lib/services/b2/client.test.ts
  - migrations/099_indicators_metadata.sql
autonomous: true
requirements: [EVID-03, EVID-04]
user_setup: []

must_haves:
  truths:
    - "AutotaskClient.getAttachmentContent() returns the attachment with populated base64 data by reading response.items[0], not response.item"
    - "b2/client.ts exports EML_OBJECT_KEY_REGEX matching phishing/{id}/{id}.eml and rejecting path-traversal / non-.eml shapes"
    - "presignDownload/presignUpload/downloadToBuffer accept an optional key-regex arg defaulting to OBJECT_KEY_REGEX (existing LogLift call sites unchanged)"
    - "indicators table has a nullable metadata JSONB column after migration 099 applies"
  artifacts:
    - path: "lib/services/autotask-client.ts"
      provides: "getAttachmentContent(entityName, entityId, attachmentId) method"
      contains: "getAttachmentContent"
    - path: "lib/services/autotask-client.test.ts"
      provides: "first AutotaskClient unit coverage — items[0] behavior"
      min_lines: 30
    - path: "lib/services/b2/client.ts"
      provides: "EML_OBJECT_KEY_REGEX + parameterized key validation"
      contains: "EML_OBJECT_KEY_REGEX"
    - path: "migrations/099_indicators_metadata.sql"
      provides: "ALTER TABLE indicators ADD COLUMN metadata JSONB (D-07)"
      contains: "metadata"
  key_links:
    - from: "lib/services/autotask-client.ts getAttachmentContent"
      to: "Autotask Tickets/{id}/Attachments/{attachmentId}"
      via: "makeApiCall GET reading response.items?.[0]"
      pattern: "items\\?\\.\\[0\\]"
    - from: "lib/services/b2/client.ts presignUpload"
      to: "EML_OBJECT_KEY_REGEX"
      via: "optional keyRegex parameter"
      pattern: "EML_OBJECT_KEY_REGEX"
---

<objective>
Build the three independent supporting pieces the Plan 03 orchestrator needs:
(1) a new AutotaskClient method that actually fetches full `.eml` base64 content,
(2) a separate B2 object-key regex + parameterized validation so raw `.eml` bytes
can be stored per D-05 without loosening LogLift's path-traversal guard, and
(3) the migration 099 `indicators.metadata` JSONB column (D-07).

Purpose: These are the load-bearing gotchas the research verified empirically —
the per-attachment-ID GET returns `{items:[...]}` (not `{item:...}`), and the B2
skill doc forbids loosening the existing regex. Getting them wrong silently
returns undefined content or weakens a security guard.
Output: modified autotask-client.ts (+ new test), modified b2/client.ts
(+ extended test), new migration 099 applied to the dev DB.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md
@.planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md
@lib/services/autotask-client.ts
@lib/services/b2/client.ts
@lib/services/b2/client.test.ts
@migrations/097_phishing_triage_schema.sql
@migrations/083_add_user_timezone.sql
@.claude/skills/pulse-overshell-b2-evidence/SKILL.md

<interfaces>
Existing (lib/types/autotask.ts):
- interface Attachment { id:number; attachmentType; fullPath:string; title:string; publish; data?:string; contentType?:string; ... }
- interface ApiResponse<T> { item?: T; items?: T[]; pageDetails?: {...} }

Existing sibling (lib/services/autotask-client.ts ~line 424) — getAttachments reads `response.items || []` (list convention). uploadAttachment (~line 417) reads `response.item` (entity convention). The NEW method must follow getAttachments' `items` convention, NOT uploadAttachment's `item`.

Existing (lib/services/b2/client.ts):
- export const OBJECT_KEY_REGEX = /^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/  (LogLift — DO NOT modify)
- export function presignDownload(objectKey, expiresInSeconds=600, cfg=getB2Config()): string
- export function presignUpload(objectKey, expiresInSeconds=1800, cfg=getB2Config()): string
- export async function downloadToBuffer(objectKey, cfg=getB2Config()): Promise<Buffer>
- export class B2InvalidObjectKeyError
- Existing call site: lib/services/rmm/executor.ts:337 → presignUpload(objectKey, 1800)

Existing (migrations/097_phishing_triage_schema.sql lines 100-108) — indicators table:
  id UUID PK, message_id UUID REFERENCES messages(id), indicator_type TEXT NOT NULL, value TEXT NOT NULL, created_at TIMESTAMPTZ. No metadata column yet.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add AutotaskClient.getAttachmentContent() + first client unit test</name>
  <files>lib/services/autotask-client.ts, lib/services/autotask-client.test.ts</files>
  <read_first>
    - lib/services/autotask-client.ts lines 380-436 (uploadAttachment reads response.item; getAttachments reads response.items — the new method must mirror getAttachments)
    - .planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md (Pattern 1 + Pitfall 2: per-attachment-ID GET returns {items:[...]}, list call always returns data:null)
    - .planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md (autotask-client section: exact method body + the "critical gotcha the new method must NOT copy")
    - lib/services/b2/client.test.ts (vitest structure to mirror for the new test file)
  </read_first>
  <behavior>
    - getAttachmentContent('Tickets', ticketId, attachmentId) issues GET {apiUrl}/Tickets/{ticketId}/Attachments/{attachmentId} and returns response.items?.[0] ?? null
    - When the mocked API returns { items: [ { id, data: '<base64>' } ] }, the method returns that attachment object with data populated
    - When the mocked API returns { items: [] } or { item: {...} } (wrong shape), the method returns null (proving it reads .items, not .item)
  </behavior>
  <action>
    Add `getAttachmentContent(entityName: string, entityId: number, attachmentId: number): Promise<Attachment | null>` to AutotaskClient immediately after `getAttachments` (~line 436). Build the URL as `${this.config.apiUrl}/${entityName}/${entityId}/Attachments/${attachmentId}`, call `this.makeApiCall<ApiResponse<Attachment>>(url, { method: 'GET', headers: this.getAuthHeaders() })`, and return `response.items?.[0] ?? null`. Add a code comment noting the response is `{items:[...]}` (list-shaped) confirmed live — NOT `{item:...}` like getEntityById/uploadAttachment. Do not wrap in try/catch (makeApiCall already catches-and-rethrows, matching sibling methods). Create `lib/services/autotask-client.test.ts` (first coverage for this class): mock `global.fetch` (or the makeApiCall transport) to return the `{items:[...]}` envelope, instantiate a client with a fixture config, and assert getAttachmentContent returns items[0]; add a second test asserting that a response shaped `{item:{...}}` yields null (guards against a future refactor copying the uploadAttachment convention).
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/autotask-client.test.ts && npx tsc --noEmit --pretty 2>&1 | (grep -i autotask-client && exit 1 || echo tsc-ok)</automated>
  </verify>
  <done>getAttachmentContent exists, reads response.items?.[0] ?? null; new test file passes both the items[0]-populated and item-shaped-returns-null cases; tsc clean.</done>
  <acceptance_criteria>
    - `grep -n "items?.\[0\]" lib/services/autotask-client.ts` matches inside getAttachmentContent
    - `npx vitest run lib/services/autotask-client.test.ts -t "getAttachmentContent"` passes
    - A test asserts a `{item:{...}}`-shaped response yields null (not the item)
    - No try/catch added around the makeApiCall invocation (matches sibling methods)
  </acceptance_criteria>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add EML_OBJECT_KEY_REGEX + parameterize B2 key validation (D-05)</name>
  <files>lib/services/b2/client.ts, lib/services/b2/client.test.ts</files>
  <read_first>
    - lib/services/b2/client.ts lines 24-52 and 145-205 (OBJECT_KEY_REGEX, B2InvalidObjectKeyError, presignDownload/presignUpload/downloadToBuffer signatures)
    - lib/services/b2/client.test.ts (existing regex/presign test conventions to extend)
    - .claude/skills/pulse-overshell-b2-evidence/SKILL.md (the rule: add a new key regex + transport, never loosen the existing one)
    - .planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md (b2/client.ts section: exact parallel regex + parameterization approach)
    - lib/services/rmm/executor.ts line 337 (existing presignUpload(objectKey, 1800) call — must keep compiling unchanged)
  </read_first>
  <behavior>
    - EML_OBJECT_KEY_REGEX matches `phishing/<reportId>/<attachmentId>.eml` where each segment is [A-Za-z0-9_-]+
    - EML_OBJECT_KEY_REGEX rejects path traversal (`phishing/../x.eml`), wrong extension (`phishing/a/b.json`), and the LogLift shape
    - presignUpload(key, ttl, cfg, EML_OBJECT_KEY_REGEX) validates against the EML regex and throws B2InvalidObjectKeyError for a non-matching key
    - presignUpload(key, ttl) with no keyRegex still validates against OBJECT_KEY_REGEX (LogLift behavior unchanged) — existing rmm executor call keeps working
  </behavior>
  <action>
    In `lib/services/b2/client.ts`, add `export const EML_OBJECT_KEY_REGEX = /^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/;` immediately after OBJECT_KEY_REGEX, with a doc comment referencing Phase 16 / D-05 and the skill-doc rule (never loosen OBJECT_KEY_REGEX). Add an optional trailing `keyRegex: RegExp = OBJECT_KEY_REGEX` parameter to `presignDownload`, `presignUpload`, and `downloadToBuffer`, and replace their hard-coded `OBJECT_KEY_REGEX.test(...)` checks with `keyRegex.test(...)`. Keep every existing parameter position and default intact so `presignUpload(objectKey, 1800)` (rmm/executor.ts:337) and all LogLift call sites compile and behave identically. Do NOT modify OBJECT_KEY_REGEX itself. Extend `lib/services/b2/client.test.ts` with a `describe('EML_OBJECT_KEY_REGEX', ...)` block asserting it matches a valid `phishing/<id>/<id>.eml` key and rejects traversal / wrong-extension / LogLift-shaped keys, plus a test that `presignUpload(validEmlKey, 1800, FIXTURE_CFG, EML_OBJECT_KEY_REGEX)` succeeds while `presignUpload(logliftKey, 1800, FIXTURE_CFG, EML_OBJECT_KEY_REGEX)` throws B2InvalidObjectKeyError.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/b2/client.test.ts && npx tsc --noEmit --pretty 2>&1 | (grep -iE 'b2/client|rmm/executor' && exit 1 || echo tsc-ok)</automated>
  </verify>
  <done>EML_OBJECT_KEY_REGEX exported and tested; presign*/downloadToBuffer take an optional keyRegex defaulting to OBJECT_KEY_REGEX; OBJECT_KEY_REGEX unchanged; b2 test suite passes; rmm/executor.ts still type-checks.</done>
  <acceptance_criteria>
    - `grep -n "EML_OBJECT_KEY_REGEX" lib/services/b2/client.ts` matches the new export
    - `git diff lib/services/b2/client.ts` shows OBJECT_KEY_REGEX line unchanged
    - A test asserts EML_OBJECT_KEY_REGEX rejects `phishing/../evil.eml`
    - A test asserts presignUpload throws B2InvalidObjectKeyError for a LogLift key when passed EML_OBJECT_KEY_REGEX
    - `npx tsc --noEmit` reports no errors for b2/client.ts or rmm/executor.ts (existing call site intact)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 3: Migration 099 — indicators.metadata JSONB (D-07) + apply to dev DB</name>
  <files>migrations/099_indicators_metadata.sql</files>
  <read_first>
    - migrations/083_add_user_timezone.sql (exact single-column-add migration shape: banner, phase/decision ref, purpose sentence, what-it-does-NOT-change sentence, ALTER ... ADD COLUMN IF NOT EXISTS, COMMENT ON COLUMN)
    - migrations/097_phishing_triage_schema.sql lines 100-108 (current indicators table this extends)
    - CLAUDE.md (migration caveat: Postgres init applies migrations on first volume boot only; existing dev DB needs manual apply via docker exec)
  </read_first>
  <action>
    Create `migrations/099_indicators_metadata.sql` following the migrations/083 doc-comment shape: a banner referencing Phase 16 / D-07, a one-sentence purpose ("lets an attachment-hash indicator carry filename/content-type/size, a URL indicator carry which message part it came from, without duplicating into the parent messages row"), and a one-sentence "does not change existing rows" note. The DDL is `ALTER TABLE indicators ADD COLUMN IF NOT EXISTS metadata JSONB;` — nullable, no NOT NULL, no default (per D-07's literal wording; no backfill needed since no rows exist yet). Add a `COMMENT ON COLUMN indicators.metadata IS '...'` line. Then apply it to the running dev database using the project's existing helper `scripts/apply-migrations.sh 099_indicators_metadata.sql` (per CLAUDE.md's guidance to prefer that script for applying migrations to an existing DB). The script already carries the project's correct credential defaults (`POSTGRES_USER:-pulse_user`, `POSTGRES_DB:-pulse_autotask`) and applies a single named migration via `docker exec -i pulse-postgres psql`, exiting non-zero on failure — do NOT hand-roll a raw `docker exec ... -U <default> -d <default>` command, whose fallback defaults would be wrong for this project (real values are `pulse_user` / `pulse_autotask`, not `pulse` / `pulse`). The migration file remains the source of truth for fresh installs; the long-lived dev volume needs this manual apply because Postgres init only runs migrations on first volume boot.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && grep -q "ADD COLUMN IF NOT EXISTS metadata JSONB" migrations/099_indicators_metadata.sql && docker exec pulse-postgres psql -U "${POSTGRES_USER:-pulse_user}" -d "${POSTGRES_DB:-pulse_autotask}" -tAc "SELECT data_type FROM information_schema.columns WHERE table_name='indicators' AND column_name='metadata'" | grep -q jsonb && echo "migration-applied"</automated>
  </verify>
  <done>migrations/099_indicators_metadata.sql exists with the ALTER + COMMENT following the 083 shape; the metadata JSONB column exists on the live dev indicators table.</done>
  <acceptance_criteria>
    - `migrations/099_indicators_metadata.sql` contains `ADD COLUMN IF NOT EXISTS metadata JSONB`
    - The file includes a banner referencing Phase 16 / D-07 and a COMMENT ON COLUMN statement
    - `information_schema.columns` reports `indicators.metadata` with data_type `jsonb` on the dev DB
    - Migration number is 099 (next after 098) and no existing migration file was edited
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Autotask REST API → AutotaskClient | Fetched base64 attachment content originates from an attacker-controlled reported email |
| Pulse server → B2 object store | Object keys constructed for raw `.eml` uploads must not enable path traversal |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-04 | Tampering / EoP | B2 object-key construction for raw `.eml` | mitigate | New EML_OBJECT_KEY_REGEX enforces `phishing/<id>/<id>.eml` shape and rejects `../` traversal; LogLift's OBJECT_KEY_REGEX is left untouched (skill-doc rule) — the guard is added in parallel, not by loosening the existing one |
| T-16-05 | Information Disclosure | getAttachmentContent returning wrong/empty data | mitigate | Method reads response.items?.[0] (verified live shape); a unit test asserts a `{item:...}`-shaped response yields null so a silent-undefined regression can't ship |
| T-16-06 | Tampering | migration 099 DDL on shared dev DB | accept | Additive `ADD COLUMN IF NOT EXISTS metadata JSONB` only — nullable, no data rewrite, idempotent; no destructive risk |
</threat_model>

<verification>
- `npx vitest run lib/services/autotask-client.test.ts lib/services/b2/client.test.ts` — green
- `npx tsc --noEmit --pretty` — no new errors (rmm/executor.ts call site still compiles)
- `information_schema` confirms indicators.metadata jsonb on the dev DB
- OBJECT_KEY_REGEX unchanged in the diff
</verification>

<success_criteria>
- EVID-03 support: getAttachmentContent fetches full base64 `.eml` content via the items[0] convention
- EVID-04 / D-05 support: raw `.eml` bytes can be stored under a dedicated, path-traversal-safe B2 key shape distinct from the persisted preview
- D-07: indicators.metadata JSONB column exists for per-indicator context
</success_criteria>

<output>
Create `.planning/phases/16-eml-mime-evidence-parser/16-02-SUMMARY.md` when done
</output>
