30 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud | 05 | execute | 3 |
|
|
true |
|
|
Purpose: SC-2 (CRUD propagates to Route 53), SC-3 (every operation logged with actor,
timestamp, before/after), SC-4 (history queryable).
Output: one library module (route53-change-submit.ts, so the AWS-command construction is
unit-testable — vitest.config.ts only includes lib/**) plus five route files.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-03-SUMMARY.mdlib/services/route53-factory.ts: isRoute53Configured(): boolean getRoute53Client(): Route53Client
lib/services/route53-record-key.ts (plan 24-02):
buildRecordKey({ zoneId, name, type, setIdentifier }): string // ${zoneId}:${name}:${type}:${setIdentifier ?? ''}
normalizeRecordSet(rs, zoneId): NormalizedRecordSet
toHistoryPayload(ns): unknown
lib/services/route53-sync-service.ts (plan 24-02): getRoute53SyncService(): Route53SyncService Route53SyncService#isSyncInProgress(): boolean Route53SyncService#fullSync(triggeredBy?): Promise Route53SyncService#incrementalSync(triggeredBy?): Promise
lib/services/route53-record-validation.ts (plan 24-03): WRITABLE_RECORD_TYPES: readonly ['A','AAAA','CNAME','MX','TXT','SRV'] validateRecordWrite(input): { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string } sanitizeAwsError(err: unknown): string
lib/services/route53-write-persistence.ts (plan 24-03): createPendingAuditLog(input): Promise<{ id: string }> markAuditCommitted(id, awsChangeId, awsChangeStatus, awsResponse): Promise markAuditFailed(id, err): Promise insertPulseCrudHistory(input): Promise upsertMirrorRecord(input): Promise softDeleteMirrorRecord(recordKey): Promise loadMirrorRecord(recordKey): Promise<MirrorRecordRow | null>
lib/auth-utils.ts: requireAuth(): Promise<{ session, error: NextResponse|null }> // 401 when unauthenticated requireAdmin(): Promise<{ session, error: NextResponse|null }> // 403 unless role is admin|super-admin // session.user has { id, email, role }
Postgres (migration 102): route53_zones, route53_records, route53_record_history, route53_audit_log Pre-existing: sync_history(entity_type='route53', sync_type IN ('full','incremental'), ...)
Task 1: Change-batch construction and bounded propagation poll lib/services/route53-change-submit.ts, lib/services/route53-change-submit.test.ts - lib/services/route53-record-key.ts (buildRecordKey, normalizeRecordSet — plan 24-02) - lib/services/route53-record-validation.ts (ValidatedRecordWrite shape, sanitizeAwsError — plan 24-03) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Code Examples: short-interval GetChange poll; Anti-Patterns: never use waitUntilResourceRecordSetsChanged in a request handler; Pitfall 3: exact-match DELETE; Pitfall 4: PriorRequestNotComplete is retryable; Pitfall 6: do not block on propagation) - `buildChangeBatch('UPSERT', { name: 'www.example.com.', type: 'A', ttl: 300, resourceRecords: [{ value: '1.2.3.4' }] })` produces `{ Changes: [{ Action: 'UPSERT', ResourceRecordSet: { Name, Type, TTL, ResourceRecords: [{ Value: '1.2.3.4' }] } }] }` - `buildChangeBatch('CREATE', ...)` sets `Action: 'CREATE'`; `buildChangeBatch('DELETE', ...)` sets `Action: 'DELETE'` - A `setIdentifier` present on the input is emitted as `SetIdentifier` on the `ResourceRecordSet`; absent/null omits the key entirely rather than emitting `undefined` - `buildChangeBatch` for a DELETE emits the full `TTL` and complete `ResourceRecords` array from the supplied current state (Route 53 requires an exact match) - `buildChangeBatch` throws when given a record type of `NS` or `SOA` — a defence-in-depth backstop independent of `validateRecordWrite` - `isRetryableAwsError` returns `true` for an error named `ThrottlingException`, `PriorRequestNotComplete`, or `Throttling`, and `false` for `InvalidChangeBatch` - `pollChangeStatus` returns `'INSYNC'` as soon as a supplied client reports `ChangeInfo.Status === 'INSYNC'` - `pollChangeStatus` returns `'PENDING'` once its timeout budget elapses without an INSYNC answer, and makes no further calls after returning Create `lib/services/route53-change-submit.ts` so AWS-command construction and the polling loop live under `lib/**` where vitest can reach them (route files under `app/api/**` are outside `vitest.config.ts`'s `include` glob).Export buildChangeBatch(action: 'CREATE' | 'UPSERT' | 'DELETE', recordSet: { name: string; type: string; ttl: number; resourceRecords: Array<{ value: string }>; setIdentifier?: string | null }): ChangeBatch
producing the @aws-sdk/client-route-53 ChangeBatch shape. Omit SetIdentifier from the
emitted object when null/undefined rather than setting it to undefined. Throw an Error
naming the type when type.toUpperCase() is NS or SOA — a second, independent D-01
enforcement point so no future caller can bypass validateRecordWrite (T-24-01,
defence in depth).
Export isRetryableAwsError(err: unknown): boolean returning true for AWS error name
values ThrottlingException, Throttling, PriorRequestNotComplete, and
ServiceUnavailable. Per 24-RESEARCH.md Pitfall 4, PriorRequestNotComplete is a per-zone
serialization constraint (two writes to the same hosted zone close together), not a hard
failure.
Export submitRecordChange(input: { zoneId: string; action: 'CREATE'|'UPSERT'|'DELETE'; recordSet: ...; client?: Route53Client }): Promise<{ changeId: string | null; awsResponse: unknown }>
— constructs ChangeResourceRecordSetsCommand({ HostedZoneId: zoneId, ChangeBatch: buildChangeBatch(...) })
and sends it. On an error where isRetryableAwsError is true, retry up to 2 additional
times with 750ms then 1500ms backoff; any other error rethrows immediately. Do 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").
Export pollChangeStatus(changeId: string, opts?: { client?: Route53Client; timeoutMs?: number; intervalMs?: number }): Promise<'INSYNC' | 'PENDING'>
— default timeoutMs: 15000, intervalMs: 2000. Loop sending GetChangeCommand({ Id: changeId })
until ChangeInfo.Status === 'INSYNC' or the budget elapses, then return 'PENDING'.
Swallow per-attempt errors (a transient GetChange failure is not a write failure — the write
was already accepted) and keep polling until the budget elapses.
CRITICAL (24-RESEARCH.md Anti-Patterns): do NOT use the SDK's
waitUntilResourceRecordSetsChanged waiter — its default config is a 30-second interval
with 60 attempts, i.e. up to 30 minutes inside an HTTP request handler.
Create lib/services/route53-change-submit.test.ts covering every <behavior> case. Test
pollChangeStatus with a hand-rolled fake client object exposing a send() that returns
canned ChangeInfo values and counts invocations — no AWS mocking library, no network. Use
a short timeoutMs/intervalMs (e.g. 50/10) so the timeout case runs in milliseconds.
Import vitest primitives explicitly (globals: false).
npx vitest run lib/services/route53-change-submit.test.ts && npx tsc --noEmit --pretty
<acceptance_criteria>
- npx vitest run lib/services/route53-change-submit.test.ts passes with at least 8 assertions covering every <behavior> bullet, completing in under 5 seconds
- grep -c 'waitUntilResourceRecordSetsChanged' lib/services/route53-change-submit.ts returns 0
- buildChangeBatch throws for NS and for SOA — asserted in the test file
- pollChangeStatus timeout case is asserted to return 'PENDING' and to have stopped calling send() after returning (invocation counter does not increase afterwards)
- npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
Change construction, retry classification, and bounded polling are tested library code with no 30-minute waiter.
app/api/route53/sync/route.ts — modelled on app/api/pax8/sync/route.ts but WITH auth:
POST:const { session, error } = await requireAdmin(); if (error) return error;then read{ syncType }from the body (default'full'). If!isRoute53Configured()return 503 with a message naming the missing AWS env vars. IfgetRoute53SyncService().isSyncInProgress()return 409{ error: 'Sync already in progress' }. Otherwise fire-and-forgetfullSync(session.user.email)orincrementalSync(...), catching in a.catch(err => console.error('[ROUTE53-SYNC] Background sync error:', sanitizeAwsError(err))), and return{ ok: true, message: 'Route 53 sync started' }immediately.GET:requireAuth()gate. Return{ inProgress, counts, history }wherecountscomes from a single query selecting(SELECT COUNT(*) FROM route53_zones WHERE is_deleted = false) AS zones, the equivalent forroute53_records, and(SELECT COUNT(*) FROM route53_record_history) AS historyRows; andhistoryfromSELECT id, sync_type, status, started_at, completed_at, records_added, records_updated, records_deleted, error_message, triggered_by FROM sync_history WHERE entity_type = 'route53' ORDER BY started_at DESC LIMIT 10. Do NOT gate either handler onintegration_settings.disabled— that is the PAX8-only exception and D-10 explicitly excludes Route 53 from it.
app/api/route53/zones/route.ts — GET with requireAuth(). Return
SELECT id, name, comment, private_zone, record_count, authoritative_name_servers, synced_at FROM route53_zones WHERE is_deleted = false ORDER BY name
transformed to the Route53Zone camelCase shape from lib/types/route53.ts.
app/api/route53/zones/[zoneId]/records/route.ts — GET with requireAuth(). Params are a
Promise in Next 16 ({ params }: { params: Promise<{ zoneId: string }> }, awaited). Return
SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target, synced_at FROM route53_records WHERE zone_id = $1 AND is_deleted = false ORDER BY name, type
transformed to Route53Record. Support optional ?type= and ?search= query params applied
as parameterized SQL predicates — never string-interpolated into the SQL. (The POST create
handler is added in Task 3 in this same file.)
app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts — GET with
requireAuth(). recordId is the URL-encoded record_key; decode it with
decodeURIComponent. Return
SELECT id, zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email, changed_at FROM route53_record_history WHERE record_key = $1 ORDER BY changed_at DESC LIMIT $2
with limit from ?limit= clamped to 1..200, default 50. Transformed to
Route53RecordHistory. This route is SC-4's "history is queryable, not just current state"
proof.
Confirm middleware.ts does not list /api/route53 among its public routes — these
endpoints must stay behind the session-cookie check, with role enforcement in the handlers.
npx tsc --noEmit --pretty && test $(grep -rl "requireAuth|requireAdmin" app/api/route53 | wc -l) -eq 4 && ! grep -rq "integration_settings" app/api/route53 && echo PASS
<acceptance_criteria>
- All four read route files exist and every exported handler begins with a requireAuth() or requireAdmin() call whose error is returned early
- grep -rc 'integration_settings' app/api/route53/ returns 0 across all files (D-10)
- POST /api/route53/sync uses requireAdmin(); GET /api/route53/sync uses requireAuth()
- grep -rn 'route53' middleware.ts returns no match (routes stay non-public)
- No SQL string interpolation of user input: grep -rn '\${' app/api/route53/*/route.ts app/api/route53/**/route.ts shows no template literal inside a SQL string containing a request-derived value
- npx tsc --noEmit --pretty exits 0
- curl -s -o /dev/null -w '%{http_code}' localhost:3100/api/route53/zones returns 401 when unauthenticated
</acceptance_criteria>
Four read surfaces exist, all auth-gated, all parameterized, none gated on the disable toggle.
Sequence for every write handler:
const { session, error } = await requireAdmin(); if (error) return error;(D-04). Never rely on the UI hiding a control (T-24-02).if (!isRoute53Configured()) return NextResponse.json({ error: 'Route 53 not configured', message: '...' }, { status: 503 });- Await
params, parse the JSON body with.catch(() => ({})). - Call
validateRecordWrite(...). On{ ok: false }returnNextResponse.json({ error: 'Invalid record', message: result.reason }, { status: 400 }). This runs BEFORE any@aws-sdk/client-route-53command object is constructed (T-24-01). For DELETE, validate the type of the record being deleted the same way — an NS/SOA delete is as destructive as an NS/SOA write. - Establish
beforeValue:- POST (create):
loadMirrorRecord(recordKey)must return null; if a record already exists return 409{ error: 'Record already exists' }.beforeValueisnull. - PATCH (update) / DELETE:
loadMirrorRecord(decodeURIComponent(recordId)); a null result returns 404. For DELETE, the loaded row's exactname,type,ttl, and fullresourceRecordsset are what gets submitted to AWS — Route 53 rejects or mis-targets a DELETE whose recordset does not match exactly (24-RESEARCH.md Pitfall 3). Never build a DELETE from only{ name, type }supplied by the client.
- POST (create):
const audit = await createPendingAuditLog({ operation, zoneId, recordKey, recordName, recordType, beforeValue, afterValue, performedByUserId: session.user.id, performedByEmail: session.user.email }). This must complete before step 7. Never call AWS without a pending audit row in flight.- In a
try:const { changeId, awsResponse } = await submitRecordChange({ zoneId, action, recordSet })withaction='CREATE'for POST,'UPSERT'for PATCH,'DELETE'for DELETE. Thenconst propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING';Thenawait markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse);Thenawait insertPulseCrudHistory({ ..., changeAction: 'create'|'update'|'delete', beforeValue, afterValue, changedByUserId: session.user.id, changedByEmail: session.user.email, auditLogId: audit.id });Then refresh the mirror:upsertMirrorRecord(...)for POST/PATCH,softDeleteMirrorRecord(recordKey)for DELETE. ReturnNextResponse.json({ auditId: audit.id, status: 'committed', propagationStatus, record })with HTTP 200 (or 201 for POST). - In the
catch:const message = sanitizeAwsError(err); await markAuditFailed(audit.id, err);thenreturn NextResponse.json({ auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message }, { status: 502 });Use 502 for AWS-side failures, matching the IT Glue write route's existing convention for "upstream integration rejected the write" (24-PATTERNS.md), not 500. Do NOT callinsertPulseCrudHistoryon this path — nothing changed on AWS's side (24-RESEARCH.md Pattern 3).
D-03 compliance: these handlers execute the mutation on the first request. Do not add a
confirm body flag, a two-phase endpoint, a pending_approval status, or any gate that
requires a second call. The audit trail is the control, not a pre-write block.
recordKey derivation: for POST, compute it with
buildRecordKey({ zoneId, name: validated.name, type: validated.type, setIdentifier }). For
PATCH/DELETE it is decodeURIComponent(recordId); verify the decoded key's zoneId prefix
matches the zoneId path param and return 400 on mismatch (prevents a caller from mutating
a record in a different zone through a mismatched path — T-24-17).
Log with a [ROUTE53-WRITE] prefix and sanitizeAwsError(err) only. Never
console.error(err) with the raw AWS error object.
npx tsc --noEmit --pretty && npm test && test $(grep -rc "requireAdmin" app/api/route53/zones/[zoneId]/records/route.ts app/api/route53/zones/[zoneId]/records/[recordId]/route.ts | awk -F: '{s+=$2} END {print s}') -ge 3 && echo PASS
<acceptance_criteria>
- app/api/route53/zones/[zoneId]/records/route.ts exports GET and POST; app/api/route53/zones/[zoneId]/records/[recordId]/route.ts exports PATCH and DELETE
- All three write handlers call requireAdmin() as their first statement and return error early (D-04)
- In each write handler, the validateRecordWrite call appears at a lower line number than any submitRecordChange / ChangeResourceRecordSetsCommand reference (D-01 enforced before command construction)
- In each write handler, createPendingAuditLog appears at a lower line number than submitRecordChange (audit row in flight before the AWS call)
- insertPulseCrudHistory appears only inside a try success path, never inside a catch: grep -A20 'catch' <file> | grep -c insertPulseCrudHistory returns 0
- Failure responses use status 502 and a sanitizeAwsError message: grep -c 'status: 502' app/api/route53/zones/\[zoneId\]/records/\[recordId\]/route.ts >= 2
- grep -rc 'pending_approval\|requiresConfirmation\|confirmToken' app/api/route53/ returns 0 (D-03 — no staged approval)
- DELETE builds its recordset from loadMirrorRecord output, not from the request body: grep -B5 -A5 "'DELETE'" app/api/route53/zones/\[zoneId\]/records/\[recordId\]/route.ts shows the mirror row's ttl/resourceRecords being passed
- npm test full suite exits 0; npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
Create/update/delete propagate to Route 53 with the audit row created first, history written only on success, failures logged with sanitized messages and returned as 502.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser / any HTTP client → /api/route53/* |
Untrusted request bodies and path params reach code that mutates live DNS |
| Pulse API route → AWS Route 53 | Authenticated mutation of a production DNS zone |
| AWS error → HTTP response body | Upstream error text returned to an authenticated caller |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-24-01 | Tampering | record type in POST/PATCH/DELETE bodies |
mitigate | validateRecordWrite (closed six-type allowlist) runs before any AWS command is constructed and returns 400 for NS/SOA; buildChangeBatch throws on the same types as an independent second gate. Line-order asserted in acceptance criteria. |
| T-24-02 | Elevation of Privilege | a user-role session calling a write route directly, bypassing UI gating |
mitigate | requireAdmin() is the first statement of every write handler (D-04), returning 403. Read routes use requireAuth() (401). middleware.ts is not modified — /api/route53/* stays outside the public-route list. Verified manually per 24-VALIDATION.md's Manual-Only table. |
| T-24-03 | Information Disclosure | AWS error text in the 502 response body and in route53_audit_log.error_message |
mitigate | Every error path returns sanitizeAwsError(err), which redacts AKIA key ids, arn:aws:* strings, and 12-digit account ids and truncates to 500 chars. Raw error objects are never logged. |
| T-24-04 | Repudiation | a DNS mutation with no attributable actor | mitigate | createPendingAuditLog runs before the AWS call with performed_by_user_id and performed_by_email from the Better Auth session; insertPulseCrudHistory records the same actor on success. A crashed request leaves a pending row as evidence of the attempt. |
| T-24-17 | Tampering | recordId path param decoding to a record in a different hosted zone than zoneId |
mitigate | The decoded record_key's zone prefix is compared against the zoneId path param; a mismatch returns 400 before any audit row or AWS call. |
| T-24-18 | Tampering | SQL injection via ?type= / ?search= / recordId query and path params |
mitigate | Every SELECT uses postgresClient.query(sql, params) parameter binding; no request-derived value is interpolated into a SQL template literal. Asserted by grep in Task 2 acceptance criteria. |
| T-24-19 | Denial of Service | an HTTP handler blocked for up to 30 minutes on DNS propagation | mitigate | pollChangeStatus is bounded at 15s / 2s intervals and returns propagationStatus: 'PENDING' on timeout; the SDK's 30s/60-attempt waitUntilResourceRecordSetsChanged waiter is explicitly not used (grep-asserted). The next incremental sync reconciles final state. |
| T-24-20 | Denial of Service | concurrent writes to one hosted zone producing PriorRequestNotComplete |
mitigate | isRetryableAwsError classifies PriorRequestNotComplete and throttling as retryable with bounded backoff (2 retries); plan 24-07's UI disables the save control while a request for that zone is in flight. |
| T-24-05 | Tampering / Spoofing | semantically malicious record values (dangling CNAME → subdomain takeover, SPF/DKIM TXT tampering) | accept | D-03 locks immediate execution with no pre-write approval gate. No semantic threat analysis is performed on record values. The compensating control is entirely post-hoc: route53_audit_log records actor, timestamp, and before/after for every attempt including failures, and route53_record_history makes the change queryable. Documented as an intentional acceptance in plan 24-01's must_haves. |
| </threat_model> |
<success_criteria>
- POST/PATCH/DELETE propagate to Route 53 and return the AWS change id plus a propagation status
- Every write attempt produces exactly one
route53_audit_logrow, transitioned to committed or failed - Committed writes produce exactly one
route53_record_historyrow taggedpulse_crud; failed writes produce none - NS/SOA writes are rejected with 400 before any AWS command is constructed
- All write routes gated by
requireAdmin(), all read routes by at leastrequireAuth() - History is queryable via
GET /api/route53/zones/{zoneId}/records/{recordId}/history - No staged-approval mechanism anywhere in the write path (D-03) </success_criteria>