wulf-pulse/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-05-PLAN.md

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
24-02
24-03
lib/services/route53-change-submit.ts
lib/services/route53-change-submit.test.ts
app/api/route53/sync/route.ts
app/api/route53/zones/route.ts
app/api/route53/zones/[zoneId]/records/route.ts
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts
app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts
true
SC-2
SC-3
SC-4
truths artifacts key_links
SC-2: Creating, updating, or deleting a record in Pulse submits a ChangeResourceRecordSetsCommand to AWS Route 53 and the change is accepted by AWS (the response carries a ChangeInfo.Id)
SC-2/D-03: Update and delete execute immediately on request — there is no staged approval, second confirmation endpoint, or pending-approval state anywhere in the write path
SC-3: Every write attempt creates a route53_audit_log row with status='pending' before the AWS call, transitioned to 'committed' or 'failed' after, carrying actor email, timestamp, and before/after values
SC-4: Committed writes append a route53_record_history row tagged source='pulse_crud'; failed writes append an audit row but no history row
D-01: A request with record type NS or SOA is rejected with HTTP 400 before any AWS command is constructed
D-04: Every write route is gated by requireAdmin(); every read route is gated by at least requireAuth()
A DELETE submits the exact current recordset (name, type, TTL, full value set) read from the mirror, because Route 53 rejects or mis-targets a DELETE that does not match exactly
D-02: No zone create/delete route exists anywhere under app/api/route53/ — this is an intentional, verified omission. app/api/route53/zones/route.ts is GET-only; hosted zones are read-only from Pulse and only records within existing zones are writable. Zone lifecycle stays in the AWS console / infra-as-code.
path provides exports
lib/services/route53-change-submit.ts ChangeResourceRecordSets construction + bounded GetChange poll, testable without a route
buildChangeBatch
submitRecordChange
pollChangeStatus
path provides exports
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts PATCH (update) and DELETE handlers with requireAdmin gating
PATCH
DELETE
path provides exports
app/api/route53/zones/[zoneId]/records/route.ts GET (list records in zone) and POST (create record)
GET
POST
path provides exports
app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts GET record change history (SC-4 queryable ledger)
GET
path provides exports
app/api/route53/sync/route.ts POST manual sync trigger + GET sync status
GET
POST
from to via pattern
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts lib/services/route53-record-validation.ts validateRecordWrite() call before any AWS command construction validateRecordWrite
from to via pattern
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts lib/services/route53-write-persistence.ts createPendingAuditLog before the AWS call, markAuditCommitted/markAuditFailed after createPendingAuditLog
from to via pattern
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts lib/auth-utils.ts requireAdmin() gate requireAdmin
from to via pattern
app/api/route53/sync/route.ts lib/services/route53-sync-service.ts getRoute53SyncService().fullSync() fire-and-forget getRoute53SyncService
Build the `/api/route53/*` surface: read routes for zones, records, and change history; a manual sync trigger; and the CRUD write routes that propagate creates, updates, and deletes to AWS Route 53 through the pending → committed/failed audit lifecycle.

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.md

lib/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.

Task 2: Read routes — zones, records, history, and sync status app/api/route53/sync/route.ts, app/api/route53/zones/route.ts, app/api/route53/zones/[zoneId]/records/route.ts, app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts - app/api/pax8/sync/route.ts (full file — fire-and-forget POST + status GET shape to copy; note it has NO auth gate, an existing gap this plan must not replicate) - app/api/pax8/companies/route.ts (list-read route shape, snake_case to camelCase transform) - app/api/analyzer/itglue/applications/[id]/audit/route.ts (append-only ledger read route shape) - lib/auth-utils.ts lines 31-45 and 79-103 (requireAuth / requireAdmin return shape) - lib/services/route53-sync-service.ts (getRoute53SyncService, isSyncInProgress — plan 24-02) - migrations/102_route53_tables.sql (exact column names for the SELECT statements) - middleware.ts (confirm /api/route53/* is NOT added to the public-route list) Create four read surfaces. Every handler follows CLAUDE.md's route conventions: `try/catch`, `NextResponse.json({ error, message }, { status })`, manual snake_case→camelCase transform, no Zod, no `'use server'`.

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. If getRoute53SyncService().isSyncInProgress() return 409 { error: 'Sync already in progress' }. Otherwise fire-and-forget fullSync(session.user.email) or incrementalSync(...), 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 } where counts comes from a single query selecting (SELECT COUNT(*) FROM route53_zones WHERE is_deleted = false) AS zones, the equivalent for route53_records, and (SELECT COUNT(*) FROM route53_record_history) AS historyRows; and history from SELECT 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 on integration_settings.disabled — that is the PAX8-only exception and D-10 explicitly excludes Route 53 from it.

app/api/route53/zones/route.tsGET 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.tsGET 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.tsGET 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.

Task 3: CRUD write routes — create, update, delete with the audit lifecycle app/api/route53/zones/[zoneId]/records/route.ts, app/api/route53/zones/[zoneId]/records/[recordId]/route.ts - app/api/analyzer/itglue/applications/[id]/apply/route.ts (full file — the canonical pending-row-before-external-call pattern, the pre-write guardrail at lines 86-94, and the 502 failure-response convention at lines 188-200) - lib/services/route53-write-persistence.ts (plan 24-03 — exact function signatures) - lib/services/route53-record-validation.ts (plan 24-03 — validateRecordWrite, sanitizeAwsError) - lib/services/route53-change-submit.ts (Task 1 of this plan) - lib/services/route53-record-key.ts (buildRecordKey, toHistoryPayload — plan 24-02) - app/api/route53/zones/[zoneId]/records/route.ts (the GET handler written in Task 2 — POST goes in this same file) - lib/auth-utils.ts lines 79-103 (requireAdmin) Add `POST` to `app/api/route53/zones/[zoneId]/records/route.ts` and create `app/api/route53/zones/[zoneId]/records/[recordId]/route.ts` exporting `PATCH` and `DELETE`. All three follow the identical eight-step sequence — factor the shared body into a local helper in the `[recordId]` file only if it does not obscure the flow; duplication across two files is acceptable here.

Sequence for every write handler:

  1. const { session, error } = await requireAdmin(); if (error) return error; (D-04). Never rely on the UI hiding a control (T-24-02).
  2. if (!isRoute53Configured()) return NextResponse.json({ error: 'Route 53 not configured', message: '...' }, { status: 503 });
  3. Await params, parse the JSON body with .catch(() => ({})).
  4. Call validateRecordWrite(...). On { ok: false } return NextResponse.json({ error: 'Invalid record', message: result.reason }, { status: 400 }). This runs BEFORE any @aws-sdk/client-route-53 command 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.
  5. Establish beforeValue:
    • POST (create): loadMirrorRecord(recordKey) must return null; if a record already exists return 409 { error: 'Record already exists' }. beforeValue is null.
    • PATCH (update) / DELETE: loadMirrorRecord(decodeURIComponent(recordId)); a null result returns 404. For DELETE, the loaded row's exact name, type, ttl, and full resourceRecords set 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.
  6. 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.
  7. In a try: const { changeId, awsResponse } = await submitRecordChange({ zoneId, action, recordSet }) with action = 'CREATE' for POST, 'UPSERT' for PATCH, 'DELETE' for DELETE. Then const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING'; Then await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse); Then await 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. Return NextResponse.json({ auditId: audit.id, status: 'committed', propagationStatus, record }) with HTTP 200 (or 201 for POST).
  8. In the catch: const message = sanitizeAwsError(err); await markAuditFailed(audit.id, err); then return 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 call insertPulseCrudHistory on 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>
- `npx vitest run lib/services/route53-change-submit.test.ts` green - `npm test` full suite green; `npx tsc --noEmit --pretty` exits 0 - Unauthenticated `curl localhost:3100/api/route53/zones` returns 401 - Manual (per 24-VALIDATION.md): signed in as a `user`-role account, `curl -X POST localhost:3100/api/route53/zones//records` returns 403; as `admin` it succeeds - Manual (per 24-VALIDATION.md): a live create/update/delete round-trip against a disposable test record produces 3 `route53_audit_log` rows with correct before/after and 3 `route53_record_history` rows tagged `pulse_crud` - Manual: `curl -X POST .../records -d '{"name":"x.example.com","type":"NS",...}'` as admin returns 400

<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_log row, transitioned to committed or failed
  • Committed writes produce exactly one route53_record_history row tagged pulse_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 least requireAuth()
  • 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>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-05-SUMMARY.md` when done.