/** * AWS Route 53 DNS sync — change-batch construction and bounded propagation * poll. * * Lives under lib/** (not app/api/**) so vitest.config.ts's `include: * ['lib/**\/*.test.ts']` glob can reach it — the AWS-command construction and * polling loop are pure/testable, and route files are not. * * D-01 defence in depth: `buildChangeBatch` throws on NS/SOA independently of * `validateRecordWrite` (lib/services/route53-record-validation.ts), which is * the primary, earlier-running gate. This is a second backstop so no future * caller can bypass it by calling this module directly. * * 24-RESEARCH.md Anti-Patterns: never use the SDK's built-in * resource-record-sets-changed waiter inside a request handler — its default * config is a 30-second interval with 60 attempts (up to 30 minutes). * `pollChangeStatus` is a short, bounded, hand-rolled poll instead. */ import { Route53Client, ChangeResourceRecordSetsCommand, GetChangeCommand, type ChangeBatch, type ChangeAction, type ResourceRecordSet, } from '@aws-sdk/client-route-53'; import { getRoute53Client } from './route53-factory'; const ZONE_DELEGATION_TYPES = new Set(['NS', 'SOA']); const RETRYABLE_ERROR_NAMES = new Set([ 'ThrottlingException', 'Throttling', 'PriorRequestNotComplete', 'ServiceUnavailable', ]); export interface ChangeSubmitRecordSet { name: string; type: string; ttl: number; resourceRecords: Array<{ value: string }>; setIdentifier?: string | null; } /** * Build a `@aws-sdk/client-route-53` ChangeBatch for a single recordset * change. Omits `SetIdentifier` from the emitted object when null/undefined * rather than setting it to `undefined` — AWS's SDK serializer treats an * explicit `undefined` property differently from an absent one in some * marshalling paths, so we simply never set the key. * * Throws when `type.toUpperCase()` is `NS` or `SOA` — a second, independent * D-01 enforcement point (T-24-01, defence in depth) so a caller cannot * bypass `validateRecordWrite` by calling this module directly. */ export function buildChangeBatch( action: 'CREATE' | 'UPSERT' | 'DELETE', recordSet: ChangeSubmitRecordSet ): ChangeBatch { const type = recordSet.type.toUpperCase(); if (ZONE_DELEGATION_TYPES.has(type)) { throw new Error( `Refusing to build a change batch for record type ${type} — NS and SOA are zone-delegation records (D-01)` ); } const resourceRecordSet: ResourceRecordSet = { Name: recordSet.name, Type: type as ResourceRecordSet['Type'], TTL: recordSet.ttl, ResourceRecords: recordSet.resourceRecords.map((r) => ({ Value: r.value })), }; if (recordSet.setIdentifier !== null && recordSet.setIdentifier !== undefined) { resourceRecordSet.SetIdentifier = recordSet.setIdentifier; } return { Changes: [ { Action: action as ChangeAction, ResourceRecordSet: resourceRecordSet, }, ], }; } /** * Classify an AWS SDK error as retryable. `PriorRequestNotComplete` is a * per-zone serialization constraint (two writes to the same hosted zone in * quick succession), not a hard failure — per 24-RESEARCH.md Pitfall 4. */ export function isRetryableAwsError(err: unknown): boolean { if (!(err instanceof Error)) return false; return RETRYABLE_ERROR_NAMES.has(err.name); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export interface SubmitRecordChangeInput { zoneId: string; action: 'CREATE' | 'UPSERT' | 'DELETE'; recordSet: ChangeSubmitRecordSet; client?: Route53Client; } export interface SubmitRecordChangeResult { changeId: string | null; awsResponse: unknown; } const RETRY_BACKOFFS_MS = [750, 1500]; /** * Construct a ChangeResourceRecordSetsCommand and send it. On a retryable * error (per `isRetryableAwsError`), retry up to 2 additional times with * 750ms then 1500ms backoff; any other error rethrows immediately. * * Does not add a general backoff wrapper around every AWS call — the SDK's * built-in retry strategy already handles transport-level retries * (24-RESEARCH.md "Don't Hand-Roll"). This retry is specifically for the * application-level PriorRequestNotComplete / throttling cases. */ export async function submitRecordChange( input: SubmitRecordChangeInput ): Promise { const client = input.client ?? getRoute53Client(); const command = new ChangeResourceRecordSetsCommand({ HostedZoneId: input.zoneId, ChangeBatch: buildChangeBatch(input.action, input.recordSet), }); let lastError: unknown; for (let attempt = 0; attempt <= RETRY_BACKOFFS_MS.length; attempt++) { try { const response = await client.send(command); return { changeId: response.ChangeInfo?.Id ?? null, awsResponse: response, }; } catch (err) { lastError = err; if (!isRetryableAwsError(err) || attempt === RETRY_BACKOFFS_MS.length) { throw err; } await sleep(RETRY_BACKOFFS_MS[attempt]); } } // Unreachable — the loop above always returns or throws — but keeps // TypeScript's control-flow analysis satisfied. throw lastError; } export interface PollChangeStatusOptions { client?: Route53Client; timeoutMs?: number; intervalMs?: number; } const DEFAULT_POLL_TIMEOUT_MS = 15000; const DEFAULT_POLL_INTERVAL_MS = 2000; /** * Bounded poll of GetChangeCommand until ChangeInfo.Status === 'INSYNC' or * the timeout budget elapses, in which case 'PENDING' is returned and no * further calls are made. Per-attempt errors are swallowed and polling * continues until the budget elapses — a transient GetChange failure is not * a write failure; the write was already accepted by AWS. * * CRITICAL: do NOT use the SDK's built-in resource-record-sets-changed * waiter here — its default config (30s interval, 60 attempts) can block an * HTTP request handler for up to 30 minutes. */ export async function pollChangeStatus( changeId: string, opts?: PollChangeStatusOptions ): Promise<'INSYNC' | 'PENDING'> { const client = opts?.client ?? getRoute53Client(); const timeoutMs = opts?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await client.send(new GetChangeCommand({ Id: changeId })); if (response.ChangeInfo?.Status === 'INSYNC') { return 'INSYNC'; } } catch { // Swallow — the write already succeeded; GetChange transiently // failing is not a write failure. Keep polling until the budget // elapses. } if (Date.now() >= deadline) break; await sleep(intervalMs); } return 'PENDING'; }