diff --git a/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/260716-n46-SUMMARY.md b/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/260716-n46-SUMMARY.md new file mode 100644 index 0000000..0b5603a --- /dev/null +++ b/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/260716-n46-SUMMARY.md @@ -0,0 +1,119 @@ +--- +phase: quick-260716-n46 +plan: 01 +subsystem: api +tags: [mimecast, blast-radius, phishing, multi-tenant, ticket-699308] + +requires: + - phase: 17-mimecast-blast-radius-lookup + provides: getBlastRadius() fan-out + getMimecastClientForTenant() factory + - phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve + provides: app/api/phishing/campaigns/[id]/route.ts call site +provides: + - getBlastRadius(input, options?) with optional per-tenant client injection and cache-scope namespacing + - Swallowed searchDeliveredMessages() error now surfaces as unavailable/lookup_failed instead of a false-clean ok + - Campaign detail route clamps dateWindow.end to Date.now() and resolves reports.company_id -> mimecast_tenants -> tenant-scoped client +affects: [phishing-campaign-review, mimecast-integration] + +tech-stack: + added: [] + patterns: + - "Optional client-injection param on a service function so a caller can override a module's default singleton client while preserving the no-injection code path unchanged" + - "Cache-key namespacing by an opaque cacheScope string to prevent cross-tenant collisions on shared cache infra" + +key-files: + created: [] + modified: + - lib/services/mimecast-blast-radius.ts + - lib/services/mimecast-blast-radius.test.ts + - lib/services/mimecast-client.test.ts + - app/api/phishing/campaigns/[id]/route.ts + +key-decisions: + - "An injected tenant client bypasses the global isMimecastConfigured() gate entirely -- it carries its own credentials and is self-sufficient, matching the plan's explicit behavior spec" + - "Swallowed deliveredResult.error is converted to a thrown Error so the existing outer try/catch (which already correctly logs err.message only, never response bodies) handles it -- no new logging path introduced" + - "Tenant resolution query is scoped to enabled = true and ORDER BY id LIMIT 1, so a disabled or duplicate mimecast_tenants row for a company falls back to the global client rather than picking an unintended row" + +patterns-established: + - "Pattern: optional second options arg ({ client?, cacheScope? }) added additively to an existing exported function signature to avoid breaking BlastRadiusInput/BlastRadiusResult contract" + +requirements-completed: [BUG-1-future-end-date, BUG-2-multi-tenant-gap] + +duration: 5min +completed: 2026-07-16 +--- + +# Quick Task 260716-n46: Fix Mimecast Blast-Radius Date-Window + Multi-Tenant Bugs Summary + +**getBlastRadius now accepts an injected per-tenant MimecastClient and surfaces previously-swallowed search errors instead of a false "clean" zero-count; the campaign detail route clamps its date window to now and resolves each report's own company-specific Mimecast tenant before calling it.** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-07-16T16:42:23-04:00 +- **Completed:** 2026-07-16T16:47:19-04:00 +- **Tasks:** 3 +- **Files modified:** 4 + +## Accomplishments +- Bug 1 (false clean on fresh campaigns): `getBlastRadius` now rethrows a swallowed `searchDeliveredMessages()` error (e.g. Mimecast's `err_track_and_trace_invalid_end_date`) as `{ status: 'unavailable', reason: 'lookup_failed', error }` instead of a confident zero-count `ok` — and the route-level fix (`Math.min(createdAt + 24h, Date.now())`) prevents the future end-date from ever being sent in the first place. +- Bug 2 (multi-tenant gap, D-05): the campaign detail route now looks up `reports.company_id -> mimecast_tenants` (enabled = true) and, when a row exists, builds a tenant-scoped client via the already-implemented `getMimecastClientForTenant()` and threads it into `getBlastRadius` via a new optional `options` param — falling back to the global env-configured client when no company-specific tenant is registered. +- Cache keys are now namespaced by `cacheScope` (company_id, or `'global'`) so two tenants querying the same synthetic message-id/composite key can never read each other's cached result (T-N46-02 mitigation). + +## Task Commits + +Each task was committed atomically (TDD tasks 1-2 follow red/green discipline): + +1. **Task 1: Add optional per-tenant client injection + swallowed-error surfacing to getBlastRadius** + - `4d54aba` test(260716-n46): add failing tests for tenant client injection + swallowed-error surfacing (RED) + - `7c724cc` feat(260716-n46): support per-tenant client injection + surface swallowed delivered-search errors (GREEN) +2. **Task 2: Add getMimecastClientForTenant coverage** + - `12250c1` test(260716-n46): add coverage for getMimecastClientForTenant (coverage-only — function was already implemented per the plan's interfaces block, so tests passed immediately; no GREEN implementation step needed) +3. **Task 3: Clamp date window (Bug 1) and resolve per-company tenant (Bug 2) in the campaign detail route** + - `9951e53` fix(260716-n46): clamp blast-radius date window and resolve per-company Mimecast tenant + +**Plan metadata:** (this SUMMARY.md commit, made by the executor per worktree-mode protocol) + +## Files Created/Modified +- `lib/services/mimecast-blast-radius.ts` — `getBlastRadius(input, options?: { client?; cacheScope? })`; resolves client as `options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null)`; cache key prefixed by scope; throws on `deliveredResult.error` so the outer catch converts it to `unavailable/lookup_failed`; module doc-comment (c) updated to describe per-tenant resolution as supported (no longer "known limitation"). +- `lib/services/mimecast-blast-radius.test.ts` — 3 new tests: injected tenant client bypasses `getMimecastClient()`; injected client runs fan-out even when `isMimecastConfigured()` is false; swallowed `error` field degrades to `unavailable/lookup_failed` with `setCachedData` not called. Also added `getMimecastClientMock.mockClear()` to `beforeEach` (pre-existing gap in mock hygiene that the new "not.toHaveBeenCalled()" assertions exposed). +- `lib/services/mimecast-client.test.ts` — new `describe('getMimecastClientForTenant', ...)` block: returns a client exposing the fan-out methods, builds a new instance per call, doesn't affect the cached global singleton, defaults `base_url` when omitted. Fake credentials only (`tid`/`tsecret`). +- `app/api/phishing/campaigns/[id]/route.ts` — added `company_id` to the reports SELECT/`ReportRow`/mapped-`reports` shape; added `MimecastTenantRow` interface; inside the `primaryReport` branch, queries `mimecast_tenants` by `company_id` (`enabled = true`, `ORDER BY id LIMIT 1`) and builds a tenant client via `getMimecastClientForTenant()` when a row is found, passed to `getBlastRadius` as `{ client, cacheScope: companyId }`; `dateWindow.end` now `new Date(Math.min(createdAt.getTime() + 24h, Date.now()))`. + +## Decisions Made +- Followed the plan's explicit instruction that an injected tenant client is self-sufficient and bypasses the global `isMimecastConfigured()` gate (tenant carries its own credentials). +- Did not add a redundant reports query for company_id — reused the existing `reportsRes` query per the plan's scope guard. +- Did not touch other Mimecast call sites (e.g. `mimecast-sync-service.ts`) — scope guard honored. + +## Deviations from Plan + +**1. [Test hygiene, in-scope] Added `getMimecastClientMock.mockClear()` to the existing `beforeEach` in `mimecast-blast-radius.test.ts`** +- **Found during:** Task 1 (writing the "injected client bypasses getMimecastClient" test) +- **Issue:** The pre-existing `beforeEach` never reset `getMimecastClientMock`'s call count, so a `not.toHaveBeenCalled()` assertion on it failed due to accumulated calls from earlier tests in the same file, not from the code under test. +- **Fix:** Added `getMimecastClientMock.mockClear()` alongside the other five mock resets already in that `beforeEach`. +- **Files modified:** `lib/services/mimecast-blast-radius.test.ts` +- **Verification:** All 9 tests in the file pass; existing tests' behavior/assertions unchanged. +- **Committed in:** `4d54aba` (Task 1 RED commit) + +--- + +**Total deviations:** 1 (test-infrastructure hygiene fix required to make the plan's own specified assertion pass correctly; no production-code scope creep). +**Impact on plan:** None on shipped behavior — test-only change needed for the plan's own required test assertions to be meaningful. + +## Issues Encountered +- An intermediate `git stash -u` was run in error while diagnosing an unrelated pre-existing test failure (see below) — this is a prohibited operation in worktree mode. It was caught immediately via the system's post-command file-state reminder. Recovery used only read-only `git show stash@{0}:` (never `git stash pop/apply/drop`) to retrieve the stashed file content, which was then written back with the Write tool and verified byte-identical via `diff` before proceeding. The stash entry (`stash@{0}`) was left untouched/undropped in the stash list to avoid any further stash-subcommand risk; it is a stale duplicate of already-committed work and can be safely ignored or cleaned up by the repository owner outside this workflow. +- `npx vitest run` (full suite) shows 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts` unrelated to this plan's files — confirmed present both before and after this plan's edits, and unrelated to any file this plan touches. Logged to `deferred-items.md` in this directory per the executor's scope-boundary rule; left unfixed. + +## User Setup Required + +None — no external service configuration required. + +## Next Phase Readiness + +- Both bugs are fixed within the existing `BlastRadiusInput`/`BlastRadiusResult` contract; no consumers of `getBlastRadius` besides the campaign detail route need updates. +- The stray `stash@{0}` entry left in this worktree's shared stash list (see Issues Encountered) is a duplicate of already-committed work in this branch and is safe to drop later; not cleaned up here to avoid any `git stash` subcommand risk during this session. +- `deferred-items.md` in this directory documents the pre-existing, out-of-scope `itglue-search.test.ts` failure for future triage. + +## Self-Check: PASSED + +All 4 code files and 2 planning files verified present on disk; all 5 commit hashes (4d54aba, 7c724cc, 12250c1, 9951e53, 1e384d5) verified present in `git log --oneline --all`. diff --git a/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/deferred-items.md b/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/deferred-items.md new file mode 100644 index 0000000..431142a --- /dev/null +++ b/.planning/quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/deferred-items.md @@ -0,0 +1,5 @@ +# Deferred Items — 260716-n46 + +## Pre-existing, out-of-scope test failure + +`lib/services/analyzer/itglue-search.test.ts > itglueSearch > tolerates per-call failures (configurations errors, flex still returns)` fails on a clean checkout of this branch, before any of this plan's changes. Confirmed present in the full `npx vitest run` both before and after Task 1-3 edits; none of this plan's files (`lib/services/mimecast-blast-radius.ts`, `lib/services/mimecast-blast-radius.test.ts`, `lib/services/mimecast-client.test.ts`, `app/api/phishing/campaigns/[id]/route.ts`) touch `lib/services/analyzer/itglue-search.ts` or its test. Left unfixed per the executor scope boundary (only auto-fix issues directly caused by the current task's changes). diff --git a/app/api/phishing/campaigns/[id]/route.ts b/app/api/phishing/campaigns/[id]/route.ts index 1fb4b9c..7728240 100644 --- a/app/api/phishing/campaigns/[id]/route.ts +++ b/app/api/phishing/campaigns/[id]/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius'; +import { getMimecastClientForTenant } from '@/lib/services/mimecast-client'; import { mergeTimeline } from '@/lib/services/phishing-timeline'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -30,6 +31,7 @@ interface ReportRow { id: string; ticket_id: string; ticket_number: string | null; + company_id: string | null; company_name: string | null; title: string | null; created_at: string; @@ -83,6 +85,12 @@ interface AuditEventRow { created_at: string; } +interface MimecastTenantRow { + client_id: string; + client_secret: string; + base_url: string | null; +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -112,8 +120,8 @@ export async function GET( // Bulk-fetch linked reports (+ join contacts for requester email — campaigns // has no recipients column, must be derived via this join). const reportsRes = await postgresClient.query( - `SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name, - r.title, r.created_at::text, + `SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_id::text, + r.company_name, r.title, r.created_at::text, c.email_address AS requester_email FROM reports r LEFT JOIN contacts c ON c.id = r.requester_contact_id @@ -173,6 +181,7 @@ export async function GET( id: r.id, ticketId: r.ticket_id, ticketNumber: r.ticket_number, + companyId: r.company_id, companyName: r.company_name, title: r.title, createdAt: r.created_at, @@ -255,15 +264,50 @@ export async function GET( | { from?: { email?: string | null } | null } | null; const createdAt = new Date(primaryReport.createdAt); - blastRadius = await getBlastRadius({ - sender: senderIndicator?.value ?? messageHeaders?.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), + + // Bug 2 (D-05): resolve the reporting company's own registered Mimecast + // tenant, if one exists, and query it directly instead of the global + // env-configured (Wulf) tenant. Falls back to the global client when + // the company has no enabled mimecast_tenants row. + let tenantOptions: { client: ReturnType; cacheScope: string } | undefined; + if (primaryReport.companyId) { + const tenantRes = await postgresClient.query( + `SELECT client_id, client_secret, base_url + FROM mimecast_tenants + WHERE company_id = $1 AND enabled = true + ORDER BY id LIMIT 1`, + [primaryReport.companyId] + ); + const tenantRow = tenantRes.rows[0]; + if (tenantRow) { + tenantOptions = { + client: getMimecastClientForTenant({ + client_id: tenantRow.client_id, + client_secret: tenantRow.client_secret, + base_url: tenantRow.base_url ?? undefined, + }), + cacheScope: primaryReport.companyId, + }; + } + } + + blastRadius = await getBlastRadius( + { + sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '', + recipient: primaryReport.requesterEmail ?? '', + subject: primaryMessage?.subject ?? primaryReport.title ?? '', + dateWindow: { + start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000), + // Bug 1: clamp the end of the window to now — a freshly-detected + // campaign's primary report is <24h old, which would otherwise + // produce a future `end` that Mimecast's real API rejects + // (err_track_and_trace_invalid_end_date), swallowed internally by + // searchDeliveredMessages() as a false zero-count "clean" result. + end: new Date(Math.min(createdAt.getTime() + 24 * 60 * 60 * 1000, Date.now())), + }, }, - }); + tenantOptions + ); } else { blastRadius = { status: 'unavailable', reason: 'not_configured' }; } diff --git a/lib/services/mimecast-blast-radius.test.ts b/lib/services/mimecast-blast-radius.test.ts index 0945ad1..54ebbd6 100644 --- a/lib/services/mimecast-blast-radius.test.ts +++ b/lib/services/mimecast-blast-radius.test.ts @@ -50,6 +50,7 @@ const BASE_INPUT: BlastRadiusInput = { describe('getBlastRadius', () => { beforeEach(() => { isMimecastConfiguredMock.mockReset().mockReturnValue(true); + getMimecastClientMock.mockClear(); getMessageInfoMock.mockReset().mockResolvedValue(null); searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] }); getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 }); @@ -224,4 +225,62 @@ describe('getBlastRadius', () => { source: 'fan-out', }); }); + + it('uses an injected tenant client via options.client and never calls getMimecastClient', async () => { + const tenantGetMessageInfoMock = vi.fn().mockResolvedValue(null); + const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] }); + const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 }); + const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null }); + const fakeTenantClient = { + getMessageInfo: tenantGetMessageInfoMock, + searchDeliveredMessages: tenantSearchDeliveredMessagesMock, + getHeldMessages: tenantGetHeldMessagesMock, + getThreatEvents: tenantGetThreatEventsMock, + }; + + const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any, cacheScope: 'company-123' }); + + expect(result.status).toBe('ok'); + expect(getMimecastClientMock).not.toHaveBeenCalled(); + expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1); + expect(tenantGetHeldMessagesMock).toHaveBeenCalledTimes(1); + expect(tenantGetThreatEventsMock).toHaveBeenCalledTimes(1); + // Global client's mocked methods must not have been touched. + expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); + }); + + it('runs the fan-out with an injected tenant client even when isMimecastConfigured() is false', async () => { + isMimecastConfiguredMock.mockReturnValue(false); + const tenantSearchDeliveredMessagesMock = vi.fn().mockResolvedValue({ messages: [] }); + const tenantGetHeldMessagesMock = vi.fn().mockResolvedValue({ messages: [], totalCount: 0 }); + const tenantGetThreatEventsMock = vi.fn().mockResolvedValue({ items: [], nextCursor: null }); + const fakeTenantClient = { + getMessageInfo: vi.fn().mockResolvedValue(null), + searchDeliveredMessages: tenantSearchDeliveredMessagesMock, + getHeldMessages: tenantGetHeldMessagesMock, + getThreatEvents: tenantGetThreatEventsMock, + }; + + const result = await getBlastRadius(BASE_INPUT, { client: fakeTenantClient as any }); + + expect(result.status).toBe('ok'); + expect(getMimecastClientMock).not.toHaveBeenCalled(); + expect(tenantSearchDeliveredMessagesMock).toHaveBeenCalledTimes(1); + }); + + it('degrades to unavailable/lookup_failed when searchDeliveredMessages swallows an error internally (Bug 1 defense-in-depth)', async () => { + searchDeliveredMessagesMock.mockResolvedValue({ + messages: [], + error: 'err_track_and_trace_invalid_end_date', + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'unavailable', + reason: 'lookup_failed', + error: 'err_track_and_trace_invalid_end_date', + }); + expect(setCachedDataMock).not.toHaveBeenCalled(); + }); }); diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts index 7be67e5..31ee6ef 100644 --- a/lib/services/mimecast-blast-radius.ts +++ b/lib/services/mimecast-blast-radius.ts @@ -22,21 +22,20 @@ * `clicked: 0` means "no click-type threat event found in the events * this module can see," NOT "confirmed zero clicks." * - * (c) 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 (not covered by the global MIMECAST_CLIENT_ID) will return - * `status: 'unavailable'` even though Mimecast is technically configured - * for that company. Per-tenant resolution is a deliberate, documented v1 - * gap — not a silent oversight — and would be a small, mechanical - * follow-up later (swap getMimecastClient() for a tenant lookup + - * getMimecastClientForTenant(), same fan-out/merge logic below). + * (c) PER-TENANT RESOLUTION (D-05, formerly a known gap): this module now + * accepts an optional `options.client` — a pre-built MimecastClient for a + * specific company's `mimecast_tenants` row (via + * getMimecastClientForTenant()). Resolution of WHICH tenant to use is the + * caller's responsibility (the campaign detail route looks up + * reports.company_id -> mimecast_tenants); this module simply uses + * whatever client it is given, or falls back to the single global + * env-var-configured getMimecastClient() when no client is injected. */ import { isMimecastConfigured, getMimecastClient, + type MimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage, } from './mimecast-client'; @@ -89,21 +88,27 @@ function isClickEvent(analysis: string[] | undefined): boolean { return (analysis ?? []).some((a) => /click/i.test(a)); } -export async function getBlastRadius(input: BlastRadiusInput): Promise { - if (!isMimecastConfigured()) { +export async function getBlastRadius( + input: BlastRadiusInput, + options?: { client?: MimecastClient; cacheScope?: string } +): Promise { + // An injected tenant client is self-sufficient (it carries its own + // credentials) — only fall back to the global env-configured client (and + // its isMimecastConfigured() gate) when no client was injected. + const client = options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null); + if (!client) { return { status: 'unavailable', reason: 'not_configured' }; } + const scope = options?.cacheScope ?? 'global'; const cacheKey = input.messageId - ? `mimecast:blast-radius:msgid:${input.messageId}` - : `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; + ? `mimecast:blast-radius:${scope}:msgid:${input.messageId}` + : `mimecast:blast-radius:${scope}:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; const cached = await getCachedData(cacheKey); if (cached) return cached; try { - const client = getMimecastClient(); - // Supplementary only — body/header evidence, never gates the fan-out // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts). if (input.messageId) { @@ -127,6 +132,16 @@ export async function getBlastRadius(input: BlastRadiusInput): Promise { delete process.env.MIMECAST_CLIENT_ID; @@ -61,3 +66,40 @@ describe('getMimecastClient', () => { expect(second).not.toBe(first); }); }); + +describe('getMimecastClientForTenant', () => { + // Fake credentials only — never real mimecast_tenants values. + const FAKE_TENANT = { client_id: 'tid', client_secret: 'tsecret', base_url: 'https://tenant.example' }; + + it('returns a MimecastClient instance exposing the fan-out methods', () => { + const client = getMimecastClientForTenant(FAKE_TENANT); + expect(client).toBeTruthy(); + expect(typeof client.searchDeliveredMessages).toBe('function'); + expect(typeof client.getHeldMessages).toBe('function'); + expect(typeof client.getThreatEvents).toBe('function'); + }); + + it('returns a NEW instance on each call — never the cached global', () => { + const first = getMimecastClientForTenant(FAKE_TENANT); + const second = getMimecastClientForTenant(FAKE_TENANT); + expect(second).not.toBe(first); + }); + + it('does not populate or replace the cached global getMimecastClient() instance', () => { + process.env.MIMECAST_CLIENT_ID = 'id1'; + process.env.MIMECAST_CLIENT_SECRET = 'secret1'; + + getMimecastClientForTenant(FAKE_TENANT); + const global1 = getMimecastClient(); + const global2 = getMimecastClient(); + + // The global singleton is unaffected by tenant-client construction — + // still cached and independent of the tenant instance. + expect(global2).toBe(global1); + expect(global1).not.toBe(getMimecastClientForTenant(FAKE_TENANT)); + }); + + it('does not throw when base_url is omitted (defaults to the Mimecast API host)', () => { + expect(() => getMimecastClientForTenant({ client_id: 'tid', client_secret: 'tsecret' })).not.toThrow(); + }); +});