docs(24): research phase domain for AWS Route 53 DNS sync
This commit is contained in:
parent
52affe348c
commit
998c4b1a36
1 changed files with 803 additions and 0 deletions
|
|
@ -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/<x>` 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/<name>-factory.ts`
|
||||
+ `is<Name>Configured()` 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 + `is<Name>Configured()` 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 <pkg>` 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 + is<Name>Configured()
|
||||
|
||||
**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 `<NAME>_*`-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 `is<Name>Configured()`-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 <touched-test-file>`
|
||||
- **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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue