---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 04
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
  - lib/services/route53-dns-delegation.ts
  - lib/services/route53-dns-delegation.test.ts
  - lib/services/integration-health.ts
autonomous: true
requirements: [SC-6]

must_haves:
  truths:
    - "SC-6: Route 53 appears in the integration health list under key 'route53', with the same not_configured / ok / auth_failed / unreachable status vocabulary every other integration uses"
    - "D-12: The health check compares each hosted zone's Route-53-authoritative NS records against a LIVE public DNS lookup for that domain, and a mismatch degrades the reported health — there is no manually-maintained 'expected NS' field anywhere"
    - "D-12: The live lookup uses a dedicated dns.Resolver() instance with setServers(['1.1.1.1','8.8.8.8']); the process-global dns.setServers() is never called, so internal service hostname resolution is unaffected"
    - "D-10: The 'route53' health result flows through the existing applyDisableOverlay(), so disabling Route 53 in /admin/integrations suppresses the health display only — no sync or CRUD path consults integration_settings"
  artifacts:
    - path: "lib/services/route53-dns-delegation.ts"
      provides: "NS normalization + mismatch detection + live resolver lookup, split so the pure half is unit-testable"
      exports: ["normalizeNsList", "compareNsDelegation", "resolveLiveNs", "checkAllZoneDelegations"]
    - path: "lib/services/route53-dns-delegation.test.ts"
      provides: "NS normalization and mismatch-detection coverage"
    - path: "lib/services/integration-health.ts"
      provides: "checkRoute53() registered in checkIntegrationHealth()'s Promise.all"
      contains: "checkRoute53"
  key_links:
    - from: "lib/services/integration-health.ts"
      to: "lib/services/route53-factory.ts"
      via: "isRoute53Configured() gate + ListHostedZonesCommand auth probe"
      pattern: "isRoute53Configured"
    - from: "lib/services/integration-health.ts"
      to: "lib/services/route53-dns-delegation.ts"
      via: "checkAllZoneDelegations() call inside checkRoute53()"
      pattern: "checkAllZoneDelegations"
    - from: "lib/services/route53-dns-delegation.ts"
      to: "node:dns"
      via: "dedicated Resolver instance with setServers"
      pattern: "new Resolver\\("
---

<objective>
Add Route 53 to the integration health system with the D-12 DNS-specific delegation check:
beyond the standard auth probe and last-sync age, compare each hosted zone's
Route-53-authoritative name servers against a live public DNS lookup and flag mismatches as
degraded health.

Purpose: SC-6 (integration appears in the existing health/admin surface alongside the
others) plus D-12's DNS-specific extension.
Output: `lib/services/route53-dns-delegation.ts` (+ tests) and a `checkRoute53()` function
registered in `lib/services/integration-health.ts`.
</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/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md
@.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md
@.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md

<interfaces>
<!-- Existing contract from lib/services/integration-health.ts — extend, do not redesign. -->

export type HealthStatus = /* union defined at line 18 — includes 'ok', 'not_configured',
   'auth_failed', 'unreachable', 'unknown', 'disabled'; read the live union before writing */

export interface IntegrationHealth {
  key: string;
  name: string;
  category: 'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity'
          | 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm';
  status: HealthStatus;
  configured: boolean;
  latencyMs?: number;
  error?: string | null;
  tokenExpiry?: TokenExpiry | null;
  checkedAt: string;
}

export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]>
  // line ~325: results = await Promise.all([ checkAutotask(), checkDattoRmm(), checkItglue(), checkS1(), ...checkConfigOnly wrappers ])
  // line ~354: const overlaid = await applyDisableOverlay(results);  <- D-10 disable overlay, already generic by key

<!-- From plan 24-01 -->
lib/services/route53-factory.ts:
  isRoute53Configured(): boolean
  getRoute53Client(): Route53Client

Postgres (migration 102):
  route53_zones(id, name, authoritative_name_servers JSONB, is_deleted, synced_at, ...)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: NS normalization and delegation-comparison module</name>
  <files>lib/services/route53-dns-delegation.ts, lib/services/route53-dns-delegation.test.ts</files>
  <read_first>
    - lib/services/pipeline-steps/ping-flap-suppress.ts line 6 (the existing `import { promises as dns } from 'dns'` precedent in this codebase)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Code Examples: checkNsDelegation; Pitfall 5: never call global dns.setServers)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (the recorded DNS-egress result — EGRESS-OK or EGRESS-BLOCKED — decides the resolver strategy in Task 2)
    - migrations/102_route53_tables.sql (route53_zones.authoritative_name_servers)
  </read_first>
  <behavior>
    - `normalizeNsList(['NS-123.AWSDNS-45.com.', 'ns-999.awsdns-01.org'])` returns `['ns-123.awsdns-45.com', 'ns-999.awsdns-01.org']` — lowercased, trailing dot stripped
    - `normalizeNsList` returns `[]` for `null`, `undefined`, and a non-array input
    - `normalizeNsList` de-duplicates and sorts, so ordering differences never register as a mismatch
    - `compareNsDelegation(authoritative, live)` with identical sets returns `{ mismatch: false, missingFromLive: [], extraInLive: [] }`
    - `compareNsDelegation` returns `mismatch: true` with a populated `missingFromLive` when an authoritative NS is absent from the live answer
    - `compareNsDelegation` returns `mismatch: true` with a populated `extraInLive` when the live answer contains an NS Route 53 does not consider authoritative
    - `compareNsDelegation(authoritative, [])` returns `mismatch: true` (a domain with no live NS answer is a delegation problem, not a pass)
    - `compareNsDelegation([], live)` returns `mismatch: false` — a zone with no recorded authoritative NS cannot be judged, so it must not produce a false alarm
    - Comparison is case-insensitive and trailing-dot-insensitive on both sides
  </behavior>
  <action>
Create `lib/services/route53-dns-delegation.ts` split into a pure half and an I/O half so the
comparison logic is unit-testable without network access.

Pure exports:
- `normalizeNsList(input: unknown): string[]` — returns `[]` for non-arrays; otherwise maps
  each entry through `String(x).trim().toLowerCase().replace(/\.$/, '')`, drops empty
  strings, de-duplicates via a `Set`, and sorts.
- `compareNsDelegation(authoritative: unknown, live: unknown): { mismatch: boolean; authoritative: string[]; live: string[]; missingFromLive: string[]; extraInLive: string[] }`
  — normalizes both sides, then computes set differences. Returns `mismatch: false` when the
  normalized authoritative list is empty (unjudgeable, not a failure). Returns
  `mismatch: true` when the normalized live list is empty but the authoritative list is not.
  Otherwise `mismatch` is `missingFromLive.length > 0 || extraInLive.length > 0`.

I/O exports:
- `resolveLiveNs(domain: string, timeoutMs = 5000): Promise<{ ok: true; nameServers: string[] } | { ok: false; error: string }>`
  — construct `new Resolver()` from `node:dns` (import `Resolver` from `'dns'`, matching the
  existing codebase precedent in `ping-flap-suppress.ts`), call
  `resolver.setServers(['1.1.1.1', '8.8.8.8'])` on that instance, then `resolveNs`
  (promisified via `util.promisify(resolver.resolveNs.bind(resolver))` or the
  `resolver.resolveNs` callback wrapped in a `Promise`). Race it against a timeout that
  calls `resolver.cancel()` and resolves `{ ok: false, error: 'DNS lookup timed out after Nms' }`.
  Strip the trailing dot from `domain` before lookup.
  CRITICAL (24-RESEARCH.md Pitfall 5): never call the module-level `dns.setServers()` — that
  would repoint DNS resolution for the entire Node process, including Postgres and Redis
  hostname resolution. Add an inline comment stating this.
- `checkAllZoneDelegations(zones: Array<{ id: string; name: string; authoritativeNameServers: unknown }>, opts?: { concurrency?: number }): Promise<Array<{ zoneId: string; zoneName: string; mismatch: boolean; error?: string; missingFromLive: string[]; extraInLive: string[] }>>`
  — resolve each zone's live NS and compare. Run at most `concurrency` (default 5) lookups in
  parallel so a large zone list does not open hundreds of concurrent UDP sockets. A lookup
  error yields `{ mismatch: false, error: <message> }` — an unreachable resolver is an
  infrastructure problem, not evidence of delegation drift, and must not be reported as a
  mismatch. Skip zones whose `authoritativeNameServers` normalizes to an empty list.

Create `lib/services/route53-dns-delegation.test.ts` covering every pure-half `<behavior>`
case. Do not test `resolveLiveNs` against a live resolver — network calls in unit tests are
flaky; that path is covered by the manual verification in 24-VALIDATION.md.
Import vitest primitives explicitly (`globals: false`).
  </action>
  <verify>
    <automated>npx vitest run lib/services/route53-dns-delegation.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/route53-dns-delegation.test.ts` passes with at least 9 assertions covering every `<behavior>` bullet
    - `grep -c 'dns.setServers\|setServers(\[.*\])' lib/services/route53-dns-delegation.ts` shows `setServers` called only on a `Resolver` instance variable, never on the imported `dns` module namespace
    - `grep -q 'new Resolver(' lib/services/route53-dns-delegation.ts`
    - `compareNsDelegation([], ['ns1.example.com'])` returns `mismatch: false` — asserted in the test file (no false alarm on unjudgeable zones)
    - `compareNsDelegation(['ns1.example.com'], [])` returns `mismatch: true` — asserted in the test file
    - The test file contains no network call: `grep -c 'resolveLiveNs' lib/services/route53-dns-delegation.test.ts` returns 0
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>Pure NS comparison logic fully unit-tested; live resolver isolated to a dedicated instance; global DNS untouched.</done>
</task>

<task type="auto">
  <name>Task 2: Register checkRoute53() in the integration health aggregator</name>
  <files>lib/services/integration-health.ts</files>
  <read_first>
    - lib/services/integration-health.ts (read in full — HealthStatus union at line ~18, IntegrationHealth interface at line ~33, checkDattoRmm() at lines 145-191 for the custom-body live-check pattern, checkItglue() at lines 193-209, checkConfigOnly() at lines 238-252, applyDisableOverlay() at lines ~310-319, checkIntegrationHealth() Promise.all at lines 325-352)
    - lib/services/route53-dns-delegation.ts (created in Task 1)
    - lib/services/route53-factory.ts (created in plan 24-01)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (DNS-egress result)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (integration-health section)
  </read_first>
  <action>
Modify `lib/services/integration-health.ts` only — do not create a parallel health module.

Extend the `IntegrationHealth` interface with two optional fields (optional so no existing
check function needs changing):
`nsDelegationMismatches?: string[] | null` (zone names with a delegation mismatch) and
`nsDelegationErrors?: string[] | null` (zone names whose live lookup failed). Do NOT overload
the existing `error` field for this — 24-PATTERNS.md calls this out explicitly.

Add `async function checkRoute53(): Promise<IntegrationHealth>` placed next to
`checkDattoRmm()`, using `key: 'route53'`, `name: 'AWS Route 53'`, `category: 'network'`
(an existing member of the category union — do not add a new category value). Behavior:

1. Config gate, mirroring `checkDattoRmm()`'s early return: if `isRoute53Configured()` is
   false, return `status: 'not_configured', configured: false` with `checkedAt` set. Do not
   construct the client.
2. Auth probe: `getRoute53Client().send(new ListHostedZonesCommand({ MaxItems: '1' }))`
   wrapped in try/catch, timing it for `latencyMs`. On an AWS SDK error whose `name` or
   `$metadata.httpStatusCode` indicates a credential/authorization problem
   (`InvalidClientTokenId`, `SignatureDoesNotMatch`, `AccessDenied`, `UnrecognizedClientException`,
   or HTTP 401/403), return `status: 'auth_failed'`. On any other error return
   `status: 'unreachable'`. In both branches set `error` to the sanitized message from
   `sanitizeAwsError` in `lib/services/route53-record-validation.ts` (T-24-03) — never the
   raw AWS error object, which carries `$metadata` including request ids.
3. D-12 delegation check: query
   `SELECT id, name, authoritative_name_servers FROM route53_zones WHERE is_deleted = false`
   and pass the rows (transformed to the `{ id, name, authoritativeNameServers }` camelCase
   shape) to `checkAllZoneDelegations()`. Collect zone names where `mismatch === true` into
   `nsDelegationMismatches` and zone names with an `error` into `nsDelegationErrors`.
   If `nsDelegationMismatches` is non-empty, downgrade the returned `status` from `'ok'` to
   the existing degraded-status member of the `HealthStatus` union — read the union at
   line ~18 and use the member that already represents "reachable but not healthy"; if the
   union has no such member, add `'degraded'` to it and confirm every consumer that
   switches on `HealthStatus` (grep for `status ===` across `app/` and `components/`)
   renders an unknown value without crashing.
   Set `error` to a summary such as
   `'NS delegation mismatch for N zone(s): example.com, other.com'` when mismatches exist.
4. Bound the total cost: if the zone list exceeds 50 zones, check only the first 50 by name
   order and note the truncation in `error`. The health check runs behind a 5-minute cache
   and must not become the slowest call in the aggregate.
5. Wrap the whole delegation step in try/catch — a Postgres failure or a blocked resolver
   must degrade to `nsDelegationErrors` and leave the auth-probe status intact, never throw
   out of `checkIntegrationHealth()`'s `Promise.all`.

If plan 24-01's SUMMARY recorded `EGRESS-BLOCKED` for the DNS smoke test, implement
`resolveLiveNs`'s fallback path instead: a DoH GET to
`https://cloudflare-dns.com/dns-query?name=<domain>&type=NS` with header
`Accept: application/dns-json`, parsing `Answer[].data` — same normalized output shape, no
new npm dependency (uses `fetch`). Note which path was taken in the SUMMARY.

Register the check in `checkIntegrationHealth()`'s `Promise.all` array (line ~325) as a bare
`checkRoute53(),` call alongside `checkAutotask()` / `checkDattoRmm()` — not wrapped in
`Promise.resolve()`, which is only used for the synchronous `checkConfigOnly()` helpers.

Do NOT add any Route 53 branch to `applyDisableOverlay()` — it already keys off
`item.key`, so the `'route53'` result is covered automatically (D-10, display-only).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -q "checkRoute53()," lib/services/integration-health.ts && grep -q "key: 'route53'" lib/services/integration-health.ts && npm test</automated>
  </verify>
  <acceptance_criteria>
    - `lib/services/integration-health.ts` contains an `async function checkRoute53()` returning `key: 'route53'`, `name: 'AWS Route 53'`, `category: 'network'`
    - `checkRoute53(),` appears inside `checkIntegrationHealth()`'s `Promise.all([...])` array, unwrapped
    - `IntegrationHealth` gained `nsDelegationMismatches?` and `nsDelegationErrors?` as optional fields; `npx tsc --noEmit --pretty` exits 0 with no changes required in any other check function
    - `grep -c 'integration_settings' lib/services/integration-health.ts` is unchanged from before this task (the disable overlay already existed; no new Route-53-specific disable logic added — D-10)
    - The auth-probe catch branch passes its error through `sanitizeAwsError`: `grep -q 'sanitizeAwsError' lib/services/integration-health.ts`
    - `curl -s localhost:3100/api/admin/integration-health` (or whichever route already serves `checkIntegrationHealth`, found by `grep -rl checkIntegrationHealth app/api`) returns a JSON array containing an object with `"key":"route53"`
    - `npm test` full suite exits 0
  </acceptance_criteria>
  <done>Route 53 appears in the health aggregate with an auth probe plus D-12 delegation check; disable overlay works via the existing generic path; no other integration's check regressed.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Pulse container → public DNS resolvers (1.1.1.1 / 8.8.8.8, UDP/53 or DoH/443) | Outbound network call to a third party whose answer influences a health verdict |
| AWS Route 53 API → health check | Auth probe error text may carry request metadata |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-13 | Denial of Service | process-global DNS resolver configuration | mitigate | `resolveLiveNs` calls `setServers` on a dedicated `new Resolver()` instance only. The process-global `dns.setServers()` is never invoked, so Postgres/Redis/AWS hostname resolution inside the container is unaffected. Asserted in Task 1 acceptance criteria. |
| T-24-14 | Denial of Service | unbounded parallel NS lookups across a large zone list | mitigate | `checkAllZoneDelegations` runs at most 5 concurrent lookups, each with a 5s timeout and `resolver.cancel()`, and `checkRoute53` caps the checked zone list at 50. The whole check sits behind the existing 5-minute health cache. |
| T-24-03 | Information Disclosure | auth-probe error surfaced in the health API response (readable by any authenticated user) | mitigate | Auth-probe errors pass through `sanitizeAwsError` before being placed in `IntegrationHealth.error`, redacting key ids, ARNs, and account ids. |
| T-24-15 | Spoofing | a third-party public resolver returning a forged NS answer | accept | The check is advisory health signalling, not an enforcement gate — a false mismatch degrades a status badge and triggers human investigation; it cannot cause a DNS mutation. Two independent resolvers (1.1.1.1 and 8.8.8.8) are configured, and lookup failures are reported as `nsDelegationErrors` rather than mismatches so an unreachable/hostile resolver cannot manufacture a false-positive drift alarm. |
| T-24-16 | Denial of Service | an exception in the delegation step aborting `Promise.all` and blanking every integration's health | mitigate | The entire delegation step is wrapped in try/catch inside `checkRoute53`; failures degrade to `nsDelegationErrors` while preserving the auth-probe status. |
</threat_model>

<verification>
- `npx vitest run lib/services/route53-dns-delegation.test.ts` green
- `npm test` full suite green
- `npx tsc --noEmit --pretty` exits 0
- The health endpoint returns a `route53` entry (curl assertion in Task 2 acceptance criteria)
- Toggling `route53` off at `/admin/integrations` flips its status to `disabled` within the 5-minute cache while a manual `POST /api/route53/sync` still works (D-10) — confirmed at the plan 24-07 checkpoint
</verification>

<success_criteria>
- `route53` present in the integration health list with the standard status vocabulary
- D-12 live NS comparison implemented against a dedicated resolver instance
- Delegation mismatches degrade the reported status and are enumerated in `nsDelegationMismatches`
- Lookup failures are reported separately and never counted as mismatches
- No process-global DNS mutation; no Route-53-specific disable gating
</success_criteria>

<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-04-SUMMARY.md` when done.
Record whether the Node `dns` path or the DoH fallback was used, and the exact
`HealthStatus` union member chosen for the degraded state.
</output>
