From 998c4b1a36d91083390f028fb929ddd6ec4b35c0 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 19:17:59 -0400 Subject: [PATCH] docs(24): research phase domain for AWS Route 53 DNS sync --- .../24-RESEARCH.md | 803 ++++++++++++++++++ 1 file changed, 803 insertions(+) create mode 100644 .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md diff --git a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md new file mode 100644 index 0000000..94a29b7 --- /dev/null +++ b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md @@ -0,0 +1,803 @@ +# Phase 24: AWS Route 53 DNS Sync - Research + +**Researched:** 2026-08-05 +**Domain:** AWS Route 53 API integration (sync + CRUD write-back) inside an existing Next.js/Postgres PSA dashboard +**Confidence:** HIGH + +## Summary + +Phase 24 adds a ninth external integration to Pulse's existing sync framework: AWS Route 53 +DNS zones/records, synced into Postgres and writable back to AWS for the common record +types (A/AAAA/CNAME/MX/TXT/SRV). Every one of this phase's open discretion questions has a +directly analogous, already-shipped pattern in this codebase — the IT Glue write-back +pipeline (`itglue_asset_audits` / `itglue_writes` / `app/api/analyzer/itglue/applications/ +[id]/apply/route.ts`) is a near-exact structural precedent for "sync a mirror, allow gated +writes back to the source of truth, log before/after including failures." The Veeam and +Datto RMM factory/sync-service pairs are the precedent for the sync half. Nothing in this +phase requires inventing a new architectural shape — it requires composing two patterns +this codebase already has, pointed at a new AWS API. + +The single new npm dependency is `@aws-sdk/client-route-53` (confirmed on the npm registry, +official `aws/aws-sdk-js-v3` monorepo, ~1.8M downloads/week, `slopcheck` verdict `[OK]`). +Its default Node credential provider chain (`@aws-sdk/credential-provider-node`, a +transitive dependency) reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / +`AWS_SESSION_TOKEN` / `AWS_REGION` from `process.env` with zero extra code — which lines up +exactly with how BWS injects secrets at the `docker-entrypoint.sh` layer (plain env vars, +before `node server.js` starts). This means the standard, literal `AWS_*` variable names +should be used (not a custom `ROUTE53_*` prefix) — using a custom prefix would force +hand-written credential wiring that defeats the purpose of relying on the SDK default chain. + +Route 53's write API (`ChangeResourceRecordSets`) models an "update" as a whole-recordset +replace (`UPSERT`), not a per-value patch — so `route53_record_history` should store +whole-recordset before/after snapshots, matching the API's actual unit of change, not +attempt finer-grained value diffing that AWS itself doesn't expose. Route 53's throttling +model was overhauled by AWS at some point before this research date (the current official +docs literally say "Amazon Route 53 updated its API throttling behavior... this page +describes the updated limits") to a token-bucket model far more generous than the commonly +cited "5 requests/second" figure still repeated on Stack Overflow and blog posts — current +limits are a 50-burst/10-per-second default per-action bucket plus a separate 1500-burst/ +100-per-second change-throughput bucket. This comfortably supports D-11's incremental + +daily-full cadence. + +**Primary recommendation:** Add `lib/services/route53-factory.ts` + +`lib/services/route53-sync-service.ts` following the Veeam/Datto RMM factory+sync-service +shape exactly; add `route53_zones` / `route53_records` / `route53_record_history` / +`route53_audit_log` in migration `102_route53_tables.sql`; implement CRUD write routes +under `app/api/route53/*` following `app/api/analyzer/itglue/applications/[id]/apply/ +route.ts`'s pending→committed/failed pattern with `requireAdmin()`; use +`@aws-sdk/client-route-53`'s default credential chain against literal `AWS_*` env vars; use +a custom short-interval poll (not the SDK's 30s/30min-default waiter) for the CRUD route's +synchronous response, backed by the next incremental sync for eventual-consistency +reconciliation; and implement the D-12 NS-delegation health check with Node's built-in +`dns` module (already used elsewhere in this codebase) against a dedicated `dns.Resolver()` +pointed at a public resolver, not the container's default resolver. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| AWS Route 53 API client (list/change/get-change) | API/Backend (`lib/services/`) | — | Credentials + AWS SDK calls must never reach the browser; factory pattern matches every other integration | +| Zone/record mirror sync | API/Backend (scheduler + service layer) | Database | `node-cron` singleton triggers; Postgres is the mirror | +| CRUD write-back to Route 53 | API/Backend (`app/api/route53/*` routes) | — | Immediate-execution writes (D-03) must be server-side, auth-gated | +| Record-level change history | Database | API/Backend (read routes) | Append-only ledger; queried, not computed live | +| Audit log (ops + failures) | Database | API/Backend | Same shape as `itglue_writes`/`audit_log` — persisted, not derived | +| NS-delegation health check | API/Backend (`integration-health.ts`) | External (live public DNS lookup via `dns` module) | Must compare Route 53's authoritative NS against a live third-party resolver — inherently crosses a network boundary from the backend | +| `/admin/sync/route53` UI | Browser/Client (`'use client'` page) | API/Backend (data source) | Matches every other `/admin/sync/` detail page in this codebase | +| Admin disable toggle | Database (`integration_settings`) | API/Backend (health check overlay) | Existing D-10-compliant mechanism, no new code pattern needed | + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Writable record types are the common set only — A, AAAA, CNAME, MX, TXT, SRV. NS + and SOA are excluded from the write path (zone-delegation records; editing them risks + breaking the zone). +- **D-02:** Records only, not zones. Pulse can create/update/delete records within hosted + zones that already exist in Route 53. Hosted zone creation/deletion (domain onboarding/ + decommissioning) stays outside Pulse (AWS console or infra-as-code). +- **D-03:** Destructive record operations (update/delete) execute immediately — no + phishing-style staged/two-step approval gate. Every change is logged with actor/timestamp/ + before/after so mistakes are traceable after the fact, not blocked beforehand. +- **D-04:** CRUD is gated at `requireAdmin()` (admin + super-admin) — the same bar as other + write-capable admin surfaces in Pulse, not a stricter super-admin-only gate. +- **D-05:** Dedicated Route 53 tables, not a reuse of the phishing pipeline's `audit_events` + table. New migration introduces `route53_zones` / `route53_records` / + `route53_record_history` / `route53_audit_log` (naming for planner/researcher to + finalize) — mirrors how Veeam and Datto RMM each own their tables rather than sharing a + cross-domain audit schema. +- **D-06:** Change history is written both for Pulse-initiated CRUD and for sync-detected + drift (a record changed outside Pulse, e.g. directly in the AWS console). Each history + row is tagged with a `source` field: `pulse_crud` | `sync_detected_drift`, so the query + "did someone change this outside Pulse?" is answerable. +- **D-07:** Failed AWS API attempts (rate-limited, invalid record, AWS-side error) are also + logged in the audit trail — attempted before/after + error message + `status: failed` — + not just successful writes. +- **D-08:** Retention is unbounded — no purge job. Matches existing Pulse convention; no + audit/history table in this codebase currently has an automatic retention/purge + mechanism. +- **D-09:** New tile on `/admin/sync` (same list as Veeam/Datto RMM/PAX8) plus a dedicated + `/admin/sync/route53` detail page for zones, records, and history — the existing + per-integration pattern, not folded into an existing page. +- **D-10:** `/admin/integrations` disable toggle for `route53` is display-only (suppresses + health-check display; scheduler/sync/CRUD keep working underneath) — the default behavior + per CLAUDE.md. Route 53 is **not** a second PAX8-style exception that blocks sync/writes + when disabled. +- **D-11:** Sync cadence is incremental + periodic full — more frequent incremental checks + plus a daily full reconciliation, rather than a single daily full sync. Trade-off (more + API calls against Route 53 rate limits for better real-time drift detection) accepted + knowingly. +- **D-12:** The health-check row for Route 53 goes beyond the generic auth-check + + last-sync-age pattern used by other integrations — it also includes a DNS-specific + delegation check: compare each hosted zone's Route-53-authoritative NS records against a + **live public DNS lookup** (e.g. Node's `dns` module or a DoH resolver) for that domain, + flagging a mismatch as degraded health. No manually-maintained "expected NS" field — the + live lookup is itself the source of truth to diff against. + +### Claude's Discretion + +- **Credentials & AWS account scope** — not discussed interactively (user deliberately + skipped this topic, treating it as already settled). Codebase scouting found uncommitted + infrastructure already in place: `docker-entrypoint.sh` (new, untracked) plus diffs to + `Dockerfile` and `docker-compose.yml` that install the `bws` CLI and wrap the app's start + command as `bws run --project-id "$BWS_PROJECT_ID" -- node server.js` when + `BWS_ACCESS_TOKEN` is set, falling back to a plain `node server.js` otherwise. This means + Bitwarden secret injection happens at the container-entrypoint layer, before the Node + process starts — the app itself never calls a BWS SDK; AWS credentials simply appear as + normal `process.env` values by the time `getRoute53Client()`-style code runs. + Researcher/planner should: (1) follow the exact existing `lib/services/-factory.ts` + + `isConfigured()` pattern used by every other integration, reading credentials from + `process.env`; (2) confirm the actual env var names with the user (e.g. + `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION`, vs a `ROUTE53_*`-prefixed + variant) before finalizing the factory — this wasn't locked in discussion; (3) add a + `ROUTE53_*` (or `AWS_*`) row to CLAUDE.md's integration env-prefix table once confirmed. +- **AWS account scope** — not discussed. Default assumption for planning purposes is a + single AWS account holding all client hosted zones (the common MSP pattern), not + per-client AWS accounts/cross-account IAM roles. Flag during research if this assumption + looks wrong once the actual AWS setup is inspected. +- **Exact record-change diff granularity** (whole-recordset replace vs individual value + diffing) — left to researcher/planner, informed by how the AWS SDK's + `ChangeResourceRecordSets` API actually models a record update. +- **Table/column naming inside the dedicated Route 53 schema** — D-05 locks "dedicated + tables," not literal names; researcher/planner should follow existing migration + conventions (`snake_case`, audit columns `created_at`/`updated_at`/`synced_at`/ + `is_deleted`/`deleted_at`). + +### Deferred Ideas (OUT OF SCOPE) + +None — discussion stayed within phase scope. The "Credentials & AWS scope" gray area was +deliberately not discussed interactively (user judged it already settled by the existing +BWS/docker-entrypoint infrastructure) — not treated as out-of-scope or deferred to a future +phase. + +## Phase Requirements + +No requirement IDs are mapped to Phase 24 in `.planning/REQUIREMENTS.md` (file does not +exist at all in this project — this project tracks scope via ROADMAP.md phase success +criteria, not a separate REQUIREMENTS.md). The six numbered Success Criteria in +ROADMAP.md's Phase 24 section function as the requirement list; each is addressed below. + +| Success Criterion | Research Support | +|----|-------------|------------------| +| SC-1: Hosted zones/records sync into Postgres on a schedule, matching AWS as source of truth | Sync-scheduler + sync-service pattern (see Architecture Patterns, Code Examples) | +| SC-2: CRUD from Pulse propagates to Route 53 via AWS API | `ChangeResourceRecordSets` write-back pattern (see Code Examples, IT Glue write precedent) | +| SC-3: Every sync/CRUD operation logged with actor, timestamp, before/after | `route53_audit_log` design (see Migration section) | +| SC-4: Record-level change history queryable, not just current state | `route53_record_history` design, `source` column (D-06) | +| SC-5: AWS credentials resolved via BWS at runtime, never persisted plaintext | Env var + credential provider chain research (see Standard Stack, Pitfalls) | +| SC-6: Integration appears in existing sync admin UI/scheduler alongside others | `sync-scheduler.ts` / `integration-health.ts` / `app/admin/sync/page.tsx` extension points (see Architecture Patterns) | + +## Project Constraints (from CLAUDE.md) + +- No ORMs — use `postgresClient` singleton (`query`, `transaction`, `upsert`, `bulkUpsert`). +- Factory + `isConfigured()` pattern for every integration client; credentials from + `process.env` only, client throws if missing. +- New numbered migration; never edit a committed one. Use `IF NOT EXISTS` + + `ON CONFLICT DO NOTHING` for seed data. Current highest is `101_reschedule_mimecast_ + sync.sql` — next is **102**. +- snake_case DB columns, camelCase API responses, manual transform in route handlers (no + ORM auto-mapping). +- Audit columns convention: `created_at`, `updated_at`, `synced_at`, `is_deleted`, + `deleted_at`. +- API routes: `try/catch`, `NextResponse.json({ error, message }, { status })`. 503 for + missing/bad config, 401/403 from auth helpers, 500 runtime. +- Auth in API routes via `lib/auth-utils.ts` (`requireAuth()`, `requireAdmin()`, + `requirePermission()`); middleware only checks a session cookie exists. +- No `'use server'` actions — API routes called via client-side `fetch()`. +- No SWR/react-query — match local `useState`/`useEffect`/`fetch()` pattern. +- Icons: `lucide-react`. Toasts: `sonner`. Tables: `@tanstack/react-table` via + `components/admin/DataTable.tsx`. Details: `components/admin/DetailModal.tsx`. +- Sync scheduler, analyzer worker, RMM worker auto-start as side effects of being imported + — don't eager-import a new Route 53 worker (there isn't one needed; sync is scheduler- + driven, not a polling worker) from a hot path or shared utility. +- `.env` is committed to the repo — treat secrets as potentially real, don't log/echo them. + **New AWS credentials must not land in `.env`** — they arrive via BWS at the + container-entrypoint layer (see Pitfalls). Flag this explicitly if a plan step suggests + adding `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` to `.env`. + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `@aws-sdk/client-route-53` | ^3.1104.0 (verified current on npm registry, 2026-08-05) | Route 53 API client (list/get/change zones and records) | Official AWS SDK v3 modular client; the only non-hand-rolled way to sign and call the Route 53 API [VERIFIED: npm registry + Context7 `/aws/aws-sdk-js-v3`] | + +No other new runtime dependency is required. `@aws-sdk/client-route-53` transitively pulls +in `@aws-sdk/credential-provider-node` (default credential chain) and `@smithy/*` (HTTP +handler, retry logic) — these do not need to be installed or imported directly for the +default (env-var) credential path. + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Node built-in `dns` module (`dns.promises`, `dns.Resolver`) | Node 18+ (bundled) | D-12 live NS-delegation health check | Already used elsewhere in this codebase (`lib/services/pipeline-steps/ping-flap-suppress.ts` imports `promises as dns`) — no new dependency, `dns.Resolver().setServers([...])` gives a dedicated instance that doesn't affect the app's default resolver [VERIFIED: codebase grep + Node core docs] | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Node built-in `dns` module for NS lookup | A DNS-over-HTTPS (DoH) client library (e.g. hitting Cloudflare's `1.1.1.1/dns-query` or Google's `dns.google/resolve` over `fetch()`) | DoH avoids depending on the container's configured resolver reaching the public internet correctly, and works even if outbound UDP/53 is firewalled in the hosting environment. But it's a new external dependency (an HTTP call to a third party) for a check `dns.Resolver` with explicit public nameservers already solves without new code. Recommend `dns` module first; fall back to DoH only if `Resolver.setServers()` proves unreliable in the actual deployment network (e.g., Docker network blocks outbound UDP/53) — this is a genuine unknown, flagged in Open Questions. | +| `@aws-sdk/client-route-53`'s aggregated `Route53` class | The modular `Route53Client` + individual `*Command` classes (`ListHostedZonesCommand`, `ChangeResourceRecordSetsCommand`, etc.) | The aggregated `Route53` class (`new Route53({ region })`) offers direct method calls (`client.listHostedZones()`) but pulls in every command's code even if unused, increasing bundle size in a serverless/edge context. This codebase runs `output: 'standalone'` in a long-lived Docker container (not edge functions), so bundle size is a non-issue either way — either style works; the modular `*Command` + `.send()` style is what Context7's official examples default to and is slightly more idiomatic for SDK v3, so prefer it for consistency with AWS's own current documentation. | + +**Installation:** +```bash +npm install @aws-sdk/client-route-53 +``` + +**Version verification:** Confirmed via `npm view @aws-sdk/client-route-53 version` → +`3.1104.0`, package first published 2020-01-14, ~1.78M downloads in the trailing week (npm +registry download API, checked 2026-08-05). Cross-referenced against Context7's +`/aws/aws-sdk-js-v3` official docs, which describe the same modular client shape. + +## Package Legitimacy Audit + +| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition | +|---------|----------|-----|-----------|--------------|-----------|--------------| +| `@aws-sdk/client-route-53` | npm | ~6 years (published 2020-01-14) | ~1.78M/week | `github.com/aws/aws-sdk-js-v3` | `[OK]` | Approved | + +**Packages removed due to slopcheck [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none + +Note on method: `slopcheck install ` in this environment performs a *real* +`npm install` as its verification step (not a registry-metadata-only check). This research +session ran it once, confirmed the `[OK]` verdict, and immediately reverted the resulting +`package.json`/`package-lock.json` changes via `git checkout` so this research task leaves +no unintended repo modification. **The planner should be aware `slopcheck install` is not +side-effect-free** — if re-run during planning/execution, expect it to actually install the +package (which may be desired at that point, but should not be run speculatively against a +clean tree without intending to keep the change). + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ node-cron scheduler │ │ Admin browser │ +│ (lib/services/ │ │ /admin/sync/route53 │ +│ sync-scheduler.ts) │ │ /admin/sync (tile) │ +│ │ └───────────┬────────────────┘ +│ route53-incremental (15m) │ │ fetch() +│ route53-full (daily) │ ▼ +└───────────┬──────────────────┘ ┌──────────────────────────────┐ + │ calls │ app/api/route53/* │ + ▼ │ GET zones/records/history │ +┌─────────────────────────────┐ │ POST/PATCH/DELETE records/[id]│ +│ Route53SyncService │ │ requireAdmin() gate │ +│ (lib/services/ │ └───────────┬────────────────┘ +│ route53-sync-service.ts) │ │ calls +│ │ ▼ +│ 1. ListHostedZones │ ┌──────────────────────────────┐ +│ 2. ListResourceRecordSets │◄───────┤ Route53 write path │ +│ (per zone, paginated) │ shares │ 1. Read current recordset │ +│ 3. Diff vs Postgres mirror │ client │ (before_value) │ +│ 4. bulkUpsert route53_records│ │ 2. INSERT route53_audit_log │ +│ 5. On diff: INSERT │ │ (status=pending) │ +│ route53_record_history │ │ 3. ChangeResourceRecordSets │ +│ (source=sync_detected_ │ │ 4. Short-interval poll │ +│ drift) │ │ GetChange (≤ ~20s) │ +└───────────┬──────────────────┘ │ 5. Mark audit_log committed/ │ + │ getRoute53Client() │ failed; INSERT record_ │ + ▼ │ history (source=pulse_crud) │ +┌─────────────────────────────┐ └───────────┬────────────────┘ +│ lib/services/ │ │ getRoute53Client() +│ route53-factory.ts │◄───────────────────┘ +│ isRoute53Configured() │ +│ getRoute53Client() │ +│ │ +│ new Route53Client({ region })│ +│ credentials: default chain │ +│ (fromEnv() reads AWS_*) │ +└───────────┬──────────────────┘ + │ signed HTTPS calls + ▼ +┌─────────────────────────────┐ +│ AWS Route 53 API │ +│ (global service, us-east-1 │ +│ signing region) │ +└─────────────────────────────┘ + +Separately, on the same 5-minute cache cadence as every other integration: + +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ integration-health.ts │───────►│ checkRoute53() │ +│ checkIntegrationHealth() │ │ 1. Auth check (ListHostedZones │ +│ │ │ with MaxItems=1) │ +└─────────────────────────────┘ │ 2. Last-sync age (route53_ │ + │ audit_log / sync_schedules) │ + │ 3. D-12: per-zone NS compare — │ + │ route53_zones.authoritative_ │ + │ name_servers vs live │ + │ dns.Resolver().resolveNs() │ + └──────────────────────────────┘ +``` + +### Recommended Project Structure +``` +lib/services/ +├── route53-factory.ts # getRoute53Client() + isRoute53Configured() +├── route53-sync-service.ts # incrementalSync() / fullSync(), mirrors veeam-sync-service.ts +└── route53-client-helpers.ts # (optional) pagination + record-set key helpers shared by sync + CRUD routes + +lib/types/ +└── route53.ts # Route53Zone, Route53Record, Route53RecordHistory, Route53AuditLog types + +migrations/ +└── 102_route53_tables.sql # route53_zones / route53_records / route53_record_history / route53_audit_log + +app/api/route53/ +├── sync/route.ts # POST trigger manual sync, GET last-sync status (mirrors /api/pax8/sync) +├── zones/route.ts # GET list zones +├── zones/[zoneId]/records/route.ts # GET list records in a zone, POST create +├── zones/[zoneId]/records/[recordId]/route.ts # PATCH update, DELETE +└── zones/[zoneId]/records/[recordId]/history/route.ts # GET record_history rows + +app/admin/sync/route53/ +└── page.tsx # zones/records/history detail page, DataTable + DetailModal +``` + +### Pattern 1: Factory + isConfigured() + +**What:** Lazy singleton client, credential-presence check separate from client +construction, matching every existing integration in this codebase. +**When to use:** Any new AWS SDK client added to `lib/services/`. +**Example:** +```typescript +// lib/services/route53-factory.ts +// Source: pattern from lib/services/veeam-factory.ts + lib/services/datto-rmm-factory.ts +import { Route53Client } from '@aws-sdk/client-route-53'; + +let route53ClientInstance: Route53Client | null = null; + +export function isRoute53Configured(): boolean { + return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY); +} + +export function getRoute53Client(): Route53Client { + if (!route53ClientInstance) { + if (!isRoute53Configured()) { + throw new Error( + 'AWS credentials missing. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_REGION) environment variables.' + ); + } + // No explicit `credentials` option: @aws-sdk/credential-provider-node's default + // chain (a transitive dependency) reads AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / + // AWS_SESSION_TOKEN from process.env automatically via fromEnv(), which is first in + // the chain. Region defaults to us-east-1 — Route 53 is a global service but the + // SDK still requires a signing region. + route53ClientInstance = new Route53Client({ + region: process.env.AWS_REGION || 'us-east-1', + }); + } + return route53ClientInstance; +} + +export function resetRoute53Client(): void { + route53ClientInstance = null; +} +``` +*[VERIFIED: Context7 `/aws/aws-sdk-js-v3` — `fromEnv()` reads `AWS_ACCESS_KEY_ID`/ +`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN`; `fromNodeProviderChain` (used implicitly by +`credential-provider-node`'s default) tries `fromEnv()` first among its provider chain]* + +### Pattern 2: Sync service — incremental + full, upsert-based + +**What:** Fetch all zones/records from AWS, diff against the Postgres mirror, `bulkUpsert`, +and write a `route53_record_history` row (`source='sync_detected_drift'`) for anything that +changed since the mirror was last read — without an operator having touched Pulse. +**When to use:** Both `route53-incremental` and `route53-full` scheduled sync types. +**Example:** +```typescript +// lib/services/route53-sync-service.ts +// Source: pattern from lib/services/veeam-sync-service.ts (executeSync/step loop) +import { ListHostedZonesCommand, ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53'; +import { getRoute53Client } from './route53-factory'; +import postgresClient from './postgres-client'; + +async function syncZones(client = getRoute53Client()) { + let marker: string | undefined; + let count = 0; + do { + const page = await client.send(new ListHostedZonesCommand({ Marker: marker })); + for (const z of page.HostedZones ?? []) { + const zoneId = (z.Id ?? '').replace('/hostedzone/', ''); + await postgresClient.query( + `INSERT INTO route53_zones (id, name, comment, private_zone, record_count, raw_payload, synced_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (id) DO UPDATE SET + name=EXCLUDED.name, comment=EXCLUDED.comment, private_zone=EXCLUDED.private_zone, + record_count=EXCLUDED.record_count, raw_payload=EXCLUDED.raw_payload, + synced_at=NOW(), updated_at=NOW()`, + [zoneId, z.Name, z.Config?.Comment ?? null, !!z.Config?.PrivateZone, + z.ResourceRecordSetCount ?? 0, JSON.stringify(z)] + ); + count++; + } + marker = page.IsTruncated ? page.NextMarker : undefined; + } while (marker); + return count; +} +``` +*[VERIFIED: Context7 `/aws/aws-sdk-js-v3` for `ListHostedZonesCommand` pagination shape +(`Marker`/`IsTruncated`/`NextMarker`); upsert SQL pattern matches +`veeam-sync-service.ts`'s `syncOrganizations()`]* + +### Pattern 3: CRUD write-back with pending → committed/failed audit row + +**What:** Insert an audit row *before* calling the external API, then flip it to +committed/failed after the call resolves — never write to the external system without an +audit row already in flight. This is the single most important pattern to reuse verbatim; +it already exists in this codebase for a structurally identical problem (IT Glue +write-back). +**When to use:** All `app/api/route53/zones/[zoneId]/records/*` POST/PATCH/DELETE routes. +**Example:** +```typescript +// Source: pattern lifted directly from +// app/api/analyzer/itglue/applications/[id]/apply/route.ts +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ zoneId: string; recordId: string }> }) { + const { session, error } = await requireAdmin(); // D-04 + if (error) return error; + + // 1. Read current recordset from the mirror (before_value). + // 2. INSERT route53_audit_log (status='pending', before_value, after_value=requested). + // 3. Call ChangeResourceRecordSetsCommand with Action: 'UPSERT'. + // 4. On success: short-interval GetChange poll (see Pitfall 6), mark committed, + // INSERT route53_record_history (source='pulse_crud'), refresh mirror row. + // 5. On failure (any thrown error, including ThrottlingException): mark audit_log + // status='failed' with the error message — D-07. Do NOT write record_history + // (nothing on the AWS side actually changed). +} +``` + +### Anti-Patterns to Avoid + +- **Per-value diffing before writing to AWS:** `ChangeResourceRecordSets` has no + "append one value to an existing multi-value record" primitive — every write replaces + the entire `ResourceRecordSet` (`Name`+`Type`+`SetIdentifier`). Don't build app-side logic + that tries to submit partial-value patches; always construct and submit the full + intended `ResourceRecords` array. +- **Using the SDK's built-in `waitUntilResourceRecordSetsChanged` waiter inside a + synchronous HTTP request handler:** its default poll interval is 30 seconds with up to 60 + attempts (30 minutes) — this is the botocore-shared waiter config the JS SDK mirrors. A + user clicking "save" in `/admin/sync/route53` should not wait 30+ seconds for a first + status check. Poll `GetChangeCommand` manually on a short interval instead (Pitfall 6). +- **Writing `.env` entries for AWS credentials:** this repo commits `.env` to git (per + CLAUDE.md's "Watch out for" section). AWS credentials must arrive via the BWS + `docker-entrypoint.sh` path, never as literal values added to the committed `.env` file. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| AWS request signing (SigV4) | A custom HMAC-SHA256 signer for Route 53's REST/XML API | `@aws-sdk/client-route-53` | Signing, retry, and pagination logic is exactly what the official SDK exists to own — Route 53's request signing is non-trivial and version-sensitive | +| Retry/backoff for Route 53 throttling | Custom exponential-backoff wrapper around every AWS call | The AWS SDK v3 client's built-in default retry strategy (3 retries, retryable-error-aware) | Already ships in every `*Client` by default; only add app-level backoff for the CRUD route's `GetChange` polling loop, which is a business-logic concern (bounded wait for UX), not a transport-retry concern | +| Live DNS resolution | A hand-rolled DNS packet parser over raw UDP sockets | Node's built-in `dns` module (`dns.promises`, `dns.Resolver`) | Already a dependency of the Node runtime, already used elsewhere in this codebase (`ping-flap-suppress.ts`) | +| Change-history diffing UI | A generic JSON-diff library for `before_value`/`after_value` | Simple field-by-field comparison in the detail-page component (record sets have a small, fixed field set: `Name`/`Type`/`TTL`/`ResourceRecords`/`SetIdentifier`) | The itglue write pattern doesn't use a diff library either — before/after are just rendered side-by-side in the existing formatted/raw `DetailModal.tsx` tabs | + +**Key insight:** Every "how do I build X" question in this phase already has a "we already +built something structurally identical" answer elsewhere in this codebase. The primary risk +in this phase isn't technical novelty — it's *not noticing* the IT Glue write-back precedent +and re-deriving a worse version of it from scratch. + +## Common Pitfalls + +### Pitfall 1: Using a custom `ROUTE53_*` env var prefix breaks the free credential chain + +**What goes wrong:** If credentials are read from custom names (`ROUTE53_ACCESS_KEY_ID` +etc.), `getRoute53Client()` must manually construct a `credentials: { accessKeyId, secretAccessKey }` object, and BWS's secret names inside the Bitwarden project must then be +configured to emit those custom names — an extra manual mapping step with no upside. +**Why it happens:** Copying the `_*`-prefix convention from every *other* integration +in CLAUDE.md's table without noticing AWS's SDK hard-codes its own env var names. +**How to avoid:** Use the literal `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / +`AWS_REGION` / (optional) `AWS_SESSION_TOKEN` names. Add a CLAUDE.md table row noting this +is an intentional exception to the per-service-prefix convention, with the rationale +(SDK-hardcoded names, not configurable). +**Warning signs:** A factory that constructs an explicit `credentials:` object instead of +omitting the option entirely. + +### Pitfall 2: Trusting the "5 requests/second" figure still repeated online + +**What goes wrong:** Designing the incremental+full sync cadence (D-11) around an outdated, +overly conservative rate limit, leading to unnecessarily long sync intervals or unnecessary +custom rate-limiting code. +**Why it happens:** AWS changed Route 53's throttling model from a flat rate limit to a +token-bucket model at some point; many blog posts, Stack Overflow answers, and even some +AWS re:Post threads still cite the old flat "5 req/s" number. +**How to avoid:** Use the current official numbers (verified 2026-08-05, `docs.aws.amazon. +com/Route53/latest/DeveloperGuide/throttling-api-requests.html` — the page explicitly +states it describes "updated limits"): 50-burst/10-per-second default request-rate bucket +per action AND account-wide combined, plus a separate 1500-burst/100-per-second +change-throughput bucket (only for zone/record-mutating actions; `ChangeResourceRecordSets` +consumes 1 token per CREATE/DELETE, 2 per UPSERT). `ListHostedZones`/ +`ListResourceRecordSets`/`GetChange` are not in the special-limits table, so they use the +50-burst/10-per-second default action bucket. +**Warning signs:** Sync code with hard sleeps between every single API call, or a sync +interval longer than necessary "just to be safe." + +### Pitfall 3: `ChangeResourceRecordSets` requires an *exact* match to delete + +**What goes wrong:** A `DELETE` action fails (or worse, silently deletes/replaces the wrong +thing) if the submitted `ResourceRecordSet` doesn't exactly match what AWS currently has — +same `Name`, `Type`, `TTL`, and full `ResourceRecords` array (values must match verbatim, +same order not required but same set). +**Why it happens:** Assuming `DELETE` only needs `Name`+`Type` (like a typical REST DELETE +by ID). +**How to avoid:** Always read the current recordset from the Postgres mirror (or, to be +safe, re-fetch fresh via `ListResourceRecordSetsCommand` scoped to that name/type +immediately before deleting) and submit that exact shape back with `Action: 'DELETE'`. +**Warning signs:** A delete route that only accepts `{ zoneId, name, type }` in its request +body without reading current TTL/values first. + +### Pitfall 4: Same-hosted-zone concurrent changes cause `PriorRequestNotComplete` + +**What goes wrong:** Two writes to the same hosted zone submitted close together (e.g., an +admin double-clicking "save," or a CRUD write racing an in-progress sync's own read) can +get rejected with `PriorRequestNotComplete` — this is a separate, per-zone serialization +constraint, distinct from account-level throttling. +**Why it happens:** Route 53 processes one `ChangeResourceRecordSets` request per hosted +zone at a time internally. +**How to avoid:** Treat this as a retryable error (short backoff + retry, same as +throttling) rather than a hard failure; disable the save button while a request for that +zone is in flight in the UI. +**Warning signs:** Intermittent `400 PriorRequestNotComplete` errors correlating with rapid +UI double-submits or sync-vs-CRUD timing overlaps. + +### Pitfall 5: `dns.resolveNs()` may reflect a cached/local resolver, not a live public lookup + +**What goes wrong:** D-12 explicitly wants a **live public DNS lookup**, but Node's default +`dns` module resolves through whatever nameserver the container's `/etc/resolv.conf` points +at (often a local caching resolver, e.g. Docker's embedded DNS or the host's resolver) — +this can return stale or non-authoritative-chain results, defeating the purpose of the +check. +**Why it happens:** `dns.promises.resolveNs()` (the global default resolver) is the +lowest-friction API, so it's tempting to reach for it directly. +**How to avoid:** Create a dedicated `new dns.Resolver()` instance and call +`.setServers(['1.1.1.1', '8.8.8.8'])` on it before calling `.resolveNs(domain)` — this +scopes the override to that one lookup without touching the app's default resolver +(important: don't call the global `dns.setServers()`, which would affect *all* DNS +resolution in the process, including internal service hostnames). +**Warning signs:** NS health checks that never flag drift even when a domain's registrar- +level NS records are known to be wrong. + +### Pitfall 6: Don't block the CRUD HTTP response on full DNS propagation + +**What goes wrong:** `ChangeResourceRecordSets` returns immediately with `Status: PENDING`; +full "INSYNC" (propagated to all Route 53 name-server edge locations) can take anywhere +from a few seconds to (rarely) a couple of minutes. Blocking the API route until `INSYNC` +using the SDK's built-in waiter risks a 30-second-to-30-minute wait (default waiter config). +**Why it happens:** Assuming `ChangeResourceRecordSets` behaves like a synchronous write. +**How to avoid:** Submit the change, write the audit/history rows immediately with the AWS +`ChangeInfo.Id`, then poll `GetChangeCommand` manually every ~2 seconds for a short bounded +window (~15-20s) purely for UI feedback ("Submitted" vs "Propagated"). If still `PENDING` +when the window elapses, return success with a `propagationStatus: 'PENDING'` flag — the +next `route53-incremental` scheduled sync (15 min later, per D-11) will reconcile the final +state regardless. This satisfies D-03's "immediate execution" (the mutation IS submitted +and accepted immediately; only global propagation is eventually consistent, which is +normal, expected AWS behavior, not a staged-approval gate). +**Warning signs:** A CRUD route that takes 30+ seconds to respond, or one that reports +success/failure based on `INSYNC` status rather than the initial `ChangeResourceRecordSets` +API acceptance. + +## Code Examples + +### Reading a hosted zone's authoritative NS records for the D-12 health check + +```typescript +// Source: pattern combining Context7 /aws/aws-sdk-js-v3 GetHostedZoneCommand shape +// with this codebase's existing dns module usage (lib/services/pipeline-steps/ping-flap-suppress.ts) +import { GetHostedZoneCommand } from '@aws-sdk/client-route-53'; +import { Resolver } from 'dns'; +import { promisify } from 'util'; + +async function checkNsDelegation(zoneId: string, zoneName: string, client = getRoute53Client()) { + const { DelegationSet } = await client.send(new GetHostedZoneCommand({ Id: zoneId })); + const authoritativeNs = (DelegationSet?.NameServers ?? []).map(ns => ns.toLowerCase().replace(/\.$/, '')); + + const resolver = new Resolver(); + resolver.setServers(['1.1.1.1', '8.8.8.8']); // dedicated instance — does not affect global dns resolution + const resolveNs = promisify(resolver.resolveNs.bind(resolver)); + + try { + const liveNs = (await resolveNs(zoneName.replace(/\.$/, ''))).map((ns: string) => ns.toLowerCase().replace(/\.$/, '')); + const mismatch = authoritativeNs.some(ns => !liveNs.includes(ns)); + return { zoneName, authoritativeNs, liveNs, mismatch }; + } catch (err) { + return { zoneName, authoritativeNs, liveNs: null, mismatch: true, error: err instanceof Error ? err.message : String(err) }; + } +} +``` + +### Short-interval GetChange poll for the CRUD route (not the SDK's default waiter) + +```typescript +// Source: pattern derived from AWS-documented ChangeInfo.Status values (PENDING|INSYNC), +// custom interval chosen for HTTP-response UX rather than using +// waitUntilResourceRecordSetsChanged's 30s/60-attempt default. +import { GetChangeCommand } from '@aws-sdk/client-route-53'; + +async function pollChangeStatus(changeId: string, client = getRoute53Client(), timeoutMs = 15000, intervalMs = 2000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const { ChangeInfo } = await client.send(new GetChangeCommand({ Id: changeId })); + if (ChangeInfo?.Status === 'INSYNC') return 'INSYNC'; + await new Promise(r => setTimeout(r, intervalMs)); + } + return 'PENDING'; // still propagating — next incremental sync reconciles +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Route 53 flat "5 requests/second per account" throttle (still widely cited online) | Token-bucket model: 50-burst/10-per-second default per-action + account-wide request-rate bucket, plus a separate 1500-burst/100-per-second change-throughput bucket for mutating actions | Unspecified exact date — AWS's own current docs describe this as an update ("Amazon Route 53 updated its API throttling behavior... increasing the requests per second limit and introducing change-based throttling") without citing when; treat any source repeating "5 req/s" as stale | D-11's incremental+full cadence has far more headroom than the old figure would suggest — no need for aggressive client-side rate limiting beyond the SDK's built-in retry/backoff | +| AWS SDK for JavaScript v2 (`aws-sdk` monolithic package, `new AWS.Route53()`) | AWS SDK for JavaScript v3 (modular `@aws-sdk/client-route-53`, `new Route53Client()` + `*Command` + `.send()`) | v3 GA ~2020, v2 in maintenance mode | Use v3 exclusively — v2 is not the standard for any new integration and would introduce a second AWS SDK major version into the dependency tree unnecessarily | + +**Deprecated/outdated:** +- The commonly-cited flat Route 53 rate limit ("5 requests per second") — superseded by the + token-bucket model described above. Any research or Stack Overflow answer using this + number as a design constraint should be treated as outdated. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | A single AWS account holds all client hosted zones (no per-client AWS accounts / cross-account IAM role assumption needed) | User Constraints, Standard Stack | If wrong, `getRoute53Client()` needs to support assuming a role per company/zone (`fromTemporaryCredentials` / `sts:AssumeRole`) rather than a single static credential pair — a materially larger scope change to the factory and sync-service design | +| A2 | The literal `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_REGION` env var names (rather than a `ROUTE53_*` prefix) is the right call, and the BWS Bitwarden Secrets Manager project can be configured to emit exactly these names | Standard Stack, Architecture Patterns, Pitfall 1 | If the BWS project is already configured with different secret key names for an unrelated reason, either the BWS project needs a naming change or the factory needs custom credential wiring — low risk, but genuinely unconfirmed since no `AWS_*`/`ROUTE53_*` keys currently exist in this repo's `.env` | +| A3 | `dns.Resolver().setServers(['1.1.1.1','8.8.8.8'])` will succeed from inside the production Docker container (i.e., outbound DNS/UDP-53 to public resolvers is not firewalled in the actual hosting environment) | Pitfall 5, Code Examples | If outbound UDP/53 to arbitrary public IPs is blocked by the hosting network/firewall, the D-12 health check will always report `unreachable`/error rather than a genuine NS mismatch — a DoH-over-HTTPS fallback (Alternatives Considered) would be needed instead | +| A4 | AWS_REGION should default to `us-east-1` when unset, since Route 53 is a global service but the SDK still requires a signing region | Architecture Patterns (Pattern 1) | Low risk — `us-east-1` is the conventional/documented choice AWS's own CLI/console uses for Route 53; if wrong, easily corrected by setting `AWS_REGION` explicitly, no data-model impact | + +## Open Questions + +1. **What are the exact BWS (Bitwarden Secrets Manager) secret key names for AWS + credentials in the actual Bitwarden project referenced by `BWS_PROJECT_ID`?** + - What we know: `docker-entrypoint.sh` runs `bws run --project-id "$BWS_PROJECT_ID" -- + node server.js`, which injects every secret in that BWS project as an env var named + after the secret's own key (that's how `bws run` works generally). + - What's unclear: Whether the project already contains AWS-related secrets under some + name, and if so, what that name is — this cannot be inspected from the repo (BWS state + lives in Bitwarden's cloud, not in this codebase). + - Recommendation: Confirm with the user (or whoever set up the BWS project) before + finalizing the factory — plan should include this as an explicit early + `checkpoint:human-verify` step, not an assumption baked into code. + +2. **Is `AWS_REGION` (or any AWS credentials) already present in any non-`.env` + configuration** (e.g., a `.env.local` not checked into git, or already set in the actual + running container)? + - What we know: No `AWS_*`/`ROUTE53_*`/`BWS_*` keys currently exist in the committed + `.env` file in this repo. + - What's unclear: Whether the production deployment already has these set outside the + committed file (a normal and expected setup for secrets, per CLAUDE.md's warning that + `.env` is committed and secrets should not live there for anything real). + - Recommendation: Ask the user to confirm before assuming the factory needs to handle a + "not yet provisioned" state gracefully vs. credentials already existing in the live + environment. + +3. **Can outbound DNS to arbitrary public resolvers (1.1.1.1/8.8.8.8 on UDP/53) actually + reach the internet from the production container's network** (see Assumption A3)? + - What we know: Nothing in this repo's `docker-compose.yml`/`Dockerfile` restricts + outbound networking explicitly (no explicit firewall rules visible), so it's likely + fine, but this is inferred, not verified against the live deployment. + - What's unclear: Actual runtime network policy of the hosting environment (Traefik/ + reverse-proxy setup visible in `docker-compose.yml` governs inbound routing, not + outbound). + - Recommendation: Planner should include a smoke-test task early (a one-off script or + admin debug endpoint calling `dns.Resolver().resolveNs('google.com')`) to confirm this + before building the full D-12 health check around it. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| `@aws-sdk/client-route-53` (npm package) | Route 53 client, all sync/CRUD | ✗ (not yet installed in `package.json`) | 3.1104.0 (target, confirmed on npm registry) | none needed — trivial `npm install` | +| AWS Route 53 hosted zones + IAM credentials | All sync/CRUD functionality | Unknown — cannot be verified from this repo; depends on the BWS project contents (Open Question 1) | — | If credentials are genuinely not yet provisioned, `isRoute53Configured()` returns `false` and every route/health-check/scheduler branch degrades gracefully (`not_configured` status), matching every other integration's pattern | +| `bws` CLI in the production container | Credential injection at container start | ✓ (per uncommitted `Dockerfile`/`docker-compose.yml` diff already reviewed) | 2.1.0 (pinned `ARG BWS_VERSION` in `Dockerfile`) | Falls back to a plain `node server.js` start (no BWS) if `BWS_ACCESS_TOKEN` unset — already implemented in `docker-entrypoint.sh` | +| Outbound DNS to public resolvers (1.1.1.1/8.8.8.8, UDP/53) | D-12 health check | Unknown — see Open Question 3 | — | DoH over HTTPS (443, already open for any web app) if UDP/53 egress is blocked | +| Node built-in `dns` module | D-12 health check | ✓ (Node core, bundled) | Node 18+ (matches project's stated Node requirement) | — | + +**Missing dependencies with no fallback:** +- AWS credentials/hosted zones being genuinely configured in the target AWS account — this + is out of this phase's control (an external prerequisite), but the code should degrade + gracefully (matching every other `isConfigured()`-gated integration) rather than + crash when absent. + +**Missing dependencies with fallback:** +- `@aws-sdk/client-route-53` — trivial `npm install`, no risk. +- Outbound public DNS — DoH fallback available if needed (Alternatives Considered). + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | vitest 4.1.5 | +| Config file | `vitest.config.ts` (`include: ['lib/**/*.test.ts']`, `environment: 'node'`) | +| Quick run command | `npx vitest run lib/services/route53-sync-service.test.ts` (once created) | +| Full suite command | `npm test` (runs all `lib/**/*.test.ts`) | + +This codebase's test coverage today is limited to `lib/services/analyzer/**`, +`lib/services/rmm/**`, `lib/services/b2/**` (per CLAUDE.md — "other parts of the codebase +have no tests"). No existing tests touch any sync-service or factory file (Veeam, Datto +RMM, PAX8 have zero `.test.ts` files). This phase can follow that same convention (type- +check as the primary safety net) or introduce the *first* sync-service tests in this +codebase — recommend at minimum testing the pure/non-network logic (record-set key +derivation, before/after diff shaping, NS-comparison normalization) since those are +easily unit-testable without mocking AWS, following the existing `target-resolver.test.ts` +style (`_INTERNALS` export pattern for testing private helpers). + +### Phase Requirement → Test Map + +| Success Criterion | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| SC-3/SC-4 (audit + history correctness) | `route53_record_history` gets a row with correct `source` tag for CRUD vs. drift | unit (pure logic: given before/after recordsets, does the diff function classify correctly) | `npx vitest run lib/services/route53-sync-service.test.ts` | ❌ Wave 0 | +| D-12 (NS delegation check) | NS-list normalization (case, trailing dot) and mismatch detection | unit | `npx vitest run lib/services/integration-health.test.ts` | ❌ Wave 0 (no existing `integration-health.test.ts` for any integration today) | +| SC-2 (CRUD write-back) | Route handler auth-gating (`requireAdmin()` returns 401/403 for non-admin) | manual / smoke (this codebase has no existing precedent for testing API route auth gating in isolation — `requirePermission`/`requireAdmin` are Better Auth session-dependent, historically verified by manual click-through per `22-VERIFICATION.md`'s precedent) | none automated | ❌ — matches existing project convention of manual auth verification | + +### Sampling Rate + +- **Per task commit:** `npx vitest run ` +- **Per wave merge:** `npm test` (full suite — cheap, this project's suite is small) +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `lib/services/route53-sync-service.test.ts` — record-set key derivation, before/after + diff classification (CREATE/UPSERT/DELETE → history row shape), NS-list normalization +- [ ] `lib/services/route53-factory.test.ts` — `isRoute53Configured()` true/false branches + (mirrors `veeam-factory.ts`'s lack of a test file today — optional, low priority, but + this is the first AWS-credential-shaped factory in the codebase and a cheap unit test) +- [ ] No framework install needed — vitest is already configured project-wide + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-------------------| +| V2 Authentication | No (new surface) | N/A — reuses existing Better Auth session, no new auth surface introduced | +| V3 Session Management | No | N/A — no session changes | +| V4 Access Control | Yes | `requireAdmin()` gate on every write route (D-04); read routes should also require at minimum `requireAuth()` (admin-area page, but the API itself has no built-in scoping — mirror the itglue/veeam pattern of gating reads at `requireAuth()` and writes at `requireAdmin()`) | +| V5 Input Validation | Yes | Record `Type` must be validated against the writable allowlist (A/AAAA/CNAME/MX/TXT/SRV — D-01) *before* constructing the AWS API call, not relying on AWS to reject NS/SOA writes. `Name`/`TTL`/`ResourceRecords` values should be validated for basic shape (no client-side Zod is mandated by CLAUDE.md, but explicit validation here matters more than most routes since a malformed `ResourceRecords` value is a *live DNS* mutation, not just a bad DB row) | +| V6 Cryptography | No | Credentials handled entirely by the AWS SDK's SigV4 signing — never hand-roll | +| V7 Error Handling & Logging | Yes | D-07 requires failed attempts logged with error message — `route53_audit_log.status='failed'` + `error_message`, matching `itglue_writes`'s pattern; never let a caught AWS SDK error leak raw stack traces to the client response body (`message` field should be a sanitized string, matching `app/api/analyzer/itglue/applications/[id]/apply/route.ts`'s `err instanceof Error ? err.message : String(err)` pattern, which itself is already somewhat permissive — recommend capping/sanitizing before returning to the client if AWS error messages ever include account-identifying ARNs) | + +### Known Threat Patterns for AWS Route 53 DNS management + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|----------------------| +| Writing to NS/SOA records, breaking zone delegation | Tampering | D-01's write-type allowlist enforced server-side (not just hidden in the UI) — reject any request targeting `Type: 'NS'` or `Type: 'SOA'` at the API route layer with a 400, before ever constructing a `ChangeResourceRecordSetsCommand` | +| Privilege escalation via a non-admin session hitting the API route directly (bypassing UI gating) | Elevation of Privilege | `requireAdmin()` server-side check (D-04) — never rely on the UI hiding buttons as the only gate | +| Credential leakage via error messages or logs | Information Disclosure | Never `console.log` the full AWS SDK error object (which can include request headers); log `error.message` only, matching existing `catch (error) { console.error(...) }` conventions in this codebase which already avoid dumping full request/response objects | +| Malicious/malformed record values (e.g., a TXT record used for SPF/DKIM policy bypass, or a CNAME pointed at an attacker-controlled domain — "dangling DNS"/subdomain takeover risk) | Tampering / Spoofing | Out of scope for input-shape validation alone — D-03 explicitly accepts immediate execution without a pre-write approval gate, relying on `route53_audit_log`'s before/after + actor for post-hoc traceability rather than pre-write blocking. Flag this tradeoff explicitly in the plan's `must_haves` so it's a documented, intentional acceptance rather than an overlooked gap | +| Route 53 API credential reuse across regions/services beyond DNS (if the same IAM user/role is later granted broader AWS permissions) | Elevation of Privilege | Recommend (as an operational note, not a code change) that the IAM policy attached to the credentials used here be scoped to Route 53 actions only (`route53:ListHostedZones`, `route53:ListResourceRecordSets`, `route53:ChangeResourceRecordSets`, `route53:GetChange`, `route53:GetHostedZone`) — least privilege. This is an AWS-console-side IAM concern, not something Pulse's code can enforce, but worth flagging for whoever provisions the credentials | + +## Sources + +### Primary (HIGH confidence) +- Context7 `/aws/aws-sdk-js-v3` — credential provider chain (`fromEnv`, `fromNodeProviderChain`), `ChangeResourceRecordSetsCommand` input/output shape, `Route53Client`/aggregated `Route53` client usage +- Context7 `/websites/aws_amazon_route53_apireference` — `ChangeResourceRecordSets` API reference, `ThrottlingException` shape +- `docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html` (fetched 2026-08-05) — quotas overview, pointer to updated throttling page +- `docs.aws.amazon.com/Route53/latest/DeveloperGuide/throttling-api-requests.html` (fetched 2026-08-05) — current token-bucket throttling model, exact burst/refill numbers, change-throughput token costs +- npm registry (`npm view @aws-sdk/client-route-53`) — version 3.1104.0, publish date, dependency list including `@aws-sdk/credential-provider-node` +- `slopcheck install @aws-sdk/client-route-53` — `[OK]` verdict (package legitimacy) +- Direct source reads: `lib/services/veeam-factory.ts`, `lib/services/veeam-sync-service.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/sync-scheduler.ts`, `lib/services/integration-health.ts`, `app/admin/sync/page.tsx`, `migrations/081_integration_settings.sql`, `migrations/091_pax8_tables.sql`, `migrations/075_itglue_audit.sql`, `app/api/analyzer/itglue/applications/[id]/apply/route.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`, `lib/services/audit.ts`, `lib/services/postgres-client.ts`, `lib/services/pipeline-steps/ping-flap-suppress.ts`, `docker-entrypoint.sh`, `git diff Dockerfile docker-compose.yml` + +### Secondary (MEDIUM confidence) +- WebSearch cross-referenced with official botocore docs — `ResourceRecordSetsChanged` waiter default config (30s delay / 60 attempts), consistent across the JS SDK v3 GitHub issue tracker and AWS's Ruby/Python SDK docs (shared underlying waiter spec) + +### Tertiary (LOW confidence) +- None retained — the initial WebSearch result citing a flat "5 requests/second" Route 53 rate limit was superseded by the official, dated-current throttling documentation fetched directly and is called out explicitly in State of the Art as outdated, not used as a design input + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — official AWS SDK docs via Context7, npm registry version check, slopcheck legitimacy pass +- Architecture: HIGH — every pattern has a direct, working precedent already in this exact codebase (Veeam/Datto RMM sync pattern, IT Glue write-back pattern) +- Pitfalls: HIGH for AWS API behavior (official docs), MEDIUM for the DNS-egress-in-production-network assumption (Open Question 3, Assumption A3 — genuinely unverifiable from the repo alone) + +**Research date:** 2026-08-05 +**Valid until:** 30 days for the architecture/pattern guidance (stable); the throttling +numbers should be re-checked if this phase's execution is delayed more than ~90 days, since +AWS has changed this model at least once already and the docs give no version/date anchor +to detect future changes automatically.