24 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 | 07 | execute | 4 |
|
|
false |
|
|
Purpose: SC-6 (the integration is usable from the existing admin surface), SC-2 (CRUD is
reachable by an operator, not only by curl), SC-4 (history is visible, not just queryable).
Output: app/admin/sync/route53/page.tsx, components/admin/route53/record-editor-dialog.tsx,
and a completed phase verification checkpoint.
<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 @DESIGN.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-CONTEXT.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-04-SUMMARY.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-05-SUMMARY.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-06-SUMMARY.mdGET /api/route53/sync -> { inProgress: boolean, counts: { zones, records, historyRows }, history: SyncHistoryRow[] } POST /api/route53/sync body { syncType?: 'full' | 'incremental' } -> { ok: true, message } | 409 { error: 'Sync already in progress' } | 503
GET /api/route53/zones -> Route53Zone[] { id, name, comment, privateZone, recordCount, authoritativeNameServers, syncedAt }
GET /api/route53/zones/{zoneId}/records?type=&search= -> Route53Record[] { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt } POST /api/route53/zones/{zoneId}/records body { name, type, ttl, resourceRecords: [{ value }] } -> 201 { auditId, status: 'committed', propagationStatus: 'INSYNC'|'PENDING', record } | 400 { error, message } (NS/SOA or invalid shape) | 409 { error: 'Record already exists' } | 502 { auditId, status: 'failed', error, message }
PATCH /api/route53/zones/{zoneId}/records/{recordId} body { name, type, ttl, resourceRecords }
DELETE /api/route53/zones/{zoneId}/records/{recordId}
-> same success/failure envelope as POST
recordId is the URL-encoded record_key: ${zoneId}:${name}:${type}:${setIdentifier ?? ''}
GET /api/route53/zones/{zoneId}/records/{recordId}/history?limit= -> Route53RecordHistory[] { id, recordName, recordType, changeAction, beforeValue, afterValue, source, changedByEmail, changedAt }
components/admin/DataTable.tsx — @tanstack/react-table wrapper: components/admin/DetailModal.tsx — formatted/raw tab detail modal components/admin/SyncScheduler.tsx — schedule editor used by every /admin/sync/ page components/ui/* — shadcn primitives (Button, Tabs, Dialog, Input, Select, Badge) lib/hooks/use-user-timezone.ts — useUserTimezone() for timestamp rendering
Task 1: Zones, records, and history page shell app/admin/sync/route53/page.tsx - app/admin/sync/veeam/page.tsx (read in full — the canonical multi-tab detail page: imports at lines 3-22, data-fetch tab pattern at lines 197-243, manual-sync-trigger at lines 567-593) - app/admin/sync/pax8/page.tsx (a more recent, smaller example of the same page shape) - components/admin/DataTable.tsx (column definition contract) - components/admin/DetailModal.tsx (formatted/raw tab props) - components/admin/SyncScheduler.tsx (props — how other pages embed the schedule editor) - lib/types/route53.ts (response shapes) - DESIGN.md (page header, spacing, and component vocabulary rules) Create `app/admin/sync/route53/page.tsx` as a `'use client'` page following `app/admin/sync/veeam/page.tsx`'s structure. Use `useState` / `useEffect` / `fetch` only — no SWR, no react-query, no server actions (CLAUDE.md).Page header: product name AWS Route 53, a back link to /admin/sync, a "Sync Now" button,
and a status line showing counts.zones / counts.records / counts.historyRows plus the
most recent sync_history entry's status and completion time (rendered through
useUserTimezone()).
Four tabs using components/ui/tabs:
-
Zones —
DataTableoverGET /api/route53/zones. Columns: name, zone id, private (badge), record count, synced at. Clicking a row selects that zone and switches to the Records tab with the zone pre-filtered. ShowauthoritativeNameServersin aDetailModaldrill-down so an operator can compare against the health check's finding from plan 24-04. -
Records — a zone selector (shadcn
Selectpopulated from the zones response) plus aDataTableoverGET /api/route53/zones/{zoneId}/records. Columns: name, type (badge), TTL, values (joinresourceRecordsvalues with a comma, truncated with a title attribute), and an actions cell. Render record values as plain text through React's default escaping — nodangerouslySetInnerHTMLanywhere on this page (T-24-25). Wire the?type=and?search=query params to a type filter and a search input. Actions cell: for a record whosetypeis one of A/AAAA/CNAME/MX/TXT/SRV, render Edit and Delete buttons; for any other type (notably NS and SOA) render a muted "read-only" label with no controls, and add atitleexplaining that NS and SOA are zone-delegation records excluded from the Pulse write path (D-01). The server-side 400 in plan 24-05 remains the actual gate — this is UI consistency, not enforcement. Also render a "New record" button that opens the editor dialog in create mode. -
History —
DataTableover the currently-selected record'sGET /api/route53/zones/{zoneId}/records/{recordId}/history, plus a zone-wide view when no record is selected. Columns: changed at, record name, type, change action (badge), source (badge — visually distinguishpulse_crudfromsync_detected_drift, since answering "did someone change this outside Pulse?" is D-06's whole point), and actor (changedByEmail, or an em dash for drift rows, which have no Pulse actor). Clicking a row opensDetailModalwith the before/after JSONB rendered side by side using the existing formatted/raw tabs — do not add a JSON-diff library (24-RESEARCH.md "Don't Hand-Roll"). -
Schedule — embed
components/admin/SyncScheduler.tsxthe same wayapp/admin/sync/veeam/page.tsxdoes, so theroute53-incrementalandroute53-fullschedules seeded in plan 24-06 are editable here.
"Sync Now" posts to /api/route53/sync, disables itself while inProgress is true, and
polls GET /api/route53/sync every 3 seconds until inProgress flips false (bounded at 20
polls), then refetches the active tab. Surface failures with toast.error from sonner;
surface a 409 as an informational toast rather than an error.
Handle the unconfigured case explicitly: if GET /api/route53/sync returns 503, render an
empty-state card explaining that AWS credentials are not configured and that they are
injected via BWS at the container entrypoint — do not render broken tables.
npx tsc --noEmit --pretty && npm run build
<acceptance_criteria>
- app/admin/sync/route53/page.tsx exists, starts with 'use client', and default-exports a component
- grep -c "dangerouslySetInnerHTML" app/admin/sync/route53/page.tsx returns 0 (T-24-25)
- grep -c "swr\|react-query\|useSWR" app/admin/sync/route53/page.tsx returns 0 (CLAUDE.md)
- The page fetches all four endpoints: grep -c "/api/route53/" app/admin/sync/route53/page.tsx >= 4
- The history table renders the source field: grep -q "sync_detected_drift" app/admin/sync/route53/page.tsx
- The actions cell gates on the writable type list: grep -q "'SRV'" app/admin/sync/route53/page.tsx
- npm run build succeeds
- npx tsc --noEmit --pretty exits 0
- Visiting /admin/sync/route53 as an admin returns HTTP 200 and renders four tab triggers
</acceptance_criteria>
The detail page renders zones, records, history with source badges, and the schedule editor, degrading cleanly when unconfigured.
Props: { open, onOpenChange, zoneId, mode: 'create' | 'edit', record?: Route53Record, onSaved: () => void }.
Form fields (plain useState, no react-hook-form — CLAUDE.md scopes react-hook-form to
admin/auth forms and this matches the surrounding /admin/sync/* pages' plain-state style):
name— text input; in edit mode it is read-only, because changing the name of a Route 53 recordset is a delete-plus-create, not an update, and the phase does not implement that.type— shadcnSelectwhose options are exactlyA,AAAA,CNAME,MX,TXT,SRV(D-01).NSandSOAmust not appear as options. Read-only in edit mode for the same reason asname.ttl— number input, default 300, constrained to 0..2147483647.resourceRecords— a repeatable list of text inputs with add/remove controls, minimum one non-empty entry, capped at 100 entries to match the server-side validator.
Submit behavior:
- create mode:
POST /api/route53/zones/{zoneId}/records - edit mode:
PATCH /api/route53/zones/{zoneId}/records/{encodeURIComponent(recordKey)}Disable the submit button while the request is in flight and until the response settles (T-24-20 — preventsPriorRequestNotCompletefrom a double-click against the same hosted zone). On a 2xx response calltoast.successincluding the returnedpropagationStatus(INSYNC→ "Propagated",PENDING→ "Submitted — propagating"), thenonSaved()and close. On 400 / 409 / 502 render the response body'smessagefield inline in the dialog AND as atoast.error, and keep the dialog open with the user's input intact so it can be corrected.
Delete: export a RecordDeleteConfirm component (or a mode: 'delete' branch of the same
dialog) that shows the record's current name, type, TTL, and full value list, plus a single
"Delete record" confirm button issuing
DELETE /api/route53/zones/{zoneId}/records/{encodeURIComponent(recordKey)}.
Per D-03 this executes immediately on confirm — do NOT add a typed-name confirmation, a
second approval step, an approver field, or any pending state. The dialog exists as a
misclick guard only; state that in a code comment so a future reader does not mistake it for
an approval workflow and does not "strengthen" it into one.
Wire both into the Records tab's actions cell from Task 1, refetching the records list and
the history list via onSaved().
npx tsc --noEmit --pretty && npm run build && test $(grep -c "'NS'|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0 && echo PASS
<acceptance_criteria>
- components/admin/route53/record-editor-dialog.tsx exports RecordEditorDialog (PascalCase from a kebab-case file)
- The type selector's option list contains exactly the six writable types; grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx returns 0
- The submit button's disabled prop is bound to an in-flight state variable (T-24-20)
- grep -c "confirmText\|typeToConfirm\|approval\|pendingApproval" components/admin/route53/record-editor-dialog.tsx returns 0 (D-03 — misclick guard only, no approval workflow)
- Delete issues method: 'DELETE' against the encoded record key: grep -q "encodeURIComponent" components/admin/route53/record-editor-dialog.tsx
- Error responses render the server message inline; a 400 leaves the dialog open (verified in the checkpoint below)
- npm run build succeeds; npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
An admin can create, edit, and delete records from the UI; double-submits are blocked; no approval workflow was introduced.
-
Create (SC-2, SC-3, SC-4). On the Records tab pick a zone, click "New record", and create a disposable TXT record (for example
pulse-phase24-test.<yourzone>with valuephase24-verification, TTL 300). Expect a success toast naming the propagation status. Confirm the record now exists in the AWS console. Then check the History tab: one row withchange_action='create',source='pulse_crud', and your email as the actor. -
Update. Edit that record's value to
phase24-verification-updated. Confirm the change in the AWS console and a second history row withchange_action='update'whosebefore_valueholds the original value. -
Delete (D-03). Delete the record. Confirm exactly one confirmation dialog appears and that confirming executes immediately with no approval step. Confirm it disappears from the AWS console and a third history row with
change_action='delete'exists. -
Audit completeness (SC-3, D-07). Run:
docker exec pulse-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT operation, status, record_name, performed_by_email, aws_change_status, left(coalesce(error_message,''),80) FROM route53_audit_log ORDER BY performed_at DESC LIMIT 10;"Expect 3 rows withstatus='committed'from steps 2-4, each with your email. -
Failure logging (D-07, T-24-01). As an admin, attempt an NS write:
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://localhost:3100/api/route53/zones/<ZONEID>/records' -H 'Content-Type: application/json' -H "Cookie: <your session cookie>" -d '{"name":"ns-test.<yourzone>","type":"NS","ttl":300,"resourceRecords":[{"value":"ns1.example.com"}]}'Expect400. Then force a genuine AWS-side failure (for example a CNAME at the zone apex, which Route 53 rejects) and confirm the response is502and aroute53_audit_logrow withstatus='failed'and a non-empty, sanitizederror_messageexists. -
Auth gating (D-04, T-24-02). Sign in as a
user-role account (or reuse its session cookie) and repeat the create curl. Expect403. ConfirmGET /api/route53/zonesstill returns 200 for that same non-admin session (reads arerequireAuth(), writes arerequireAdmin()). -
Drift detection (D-06). Change a record directly in the AWS console (edit any TXT value in a synced zone), then click "Sync Now" in Pulse. Expect a history row for that record with
source='sync_detected_drift'and a null actor. -
Health check (D-12, SC-6). Visit
/admin/integrations(or whichever page renderscheckIntegrationHealth) and confirm an "AWS Route 53" row with a live status. If a zone's registrar-level NS records genuinely differ from Route 53's delegation set, confirm the row reports the mismatch. Then toggle Route 53 off at/admin/integrations, wait for the 5-minute health cache (or trigger the PATCH which clears it), and confirm the health row showsdisabledwhile a manualPOST /api/route53/syncstill succeeds — that is D-10's display-only behavior, and its failure mode (sync silently stopping) is the specific thing this step exists to catch. Reply "approved" if all nine steps behave as described, or list the step numbers that failed with what you observed instead.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| AWS-sourced record values → rendered admin page | Data controlled outside Pulse is displayed to an authenticated admin |
Admin browser → /api/route53/* write routes |
Operator-initiated live DNS mutation |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-24-25 | Tampering (stored XSS) | record values, history before/after JSONB, and AWS error messages rendered on the page | mitigate | All values render as text through React's default escaping. dangerouslySetInnerHTML is absent from both files (grep-asserted). Record values are never rendered as anchors or src/href attributes, so an attacker-controlled CNAME/TXT value cannot become a navigable link. |
| T-24-02 | Elevation of Privilege | edit/delete controls visible to a non-admin session | mitigate | UI gating is cosmetic; the real control is requireAdmin() on every write route (plan 24-05). Checkpoint step 7 verifies a user-role session receives 403 when calling the API directly, independent of what the UI shows. |
| T-24-20 | Denial of Service | rapid double-submit producing PriorRequestNotComplete on the same hosted zone |
mitigate | Submit and delete controls bind disabled to an in-flight state variable; the server side additionally classifies PriorRequestNotComplete as retryable with bounded backoff (plan 24-05). |
| T-24-01 | Tampering | NS/SOA edit reachable from the UI | mitigate | The type selector offers only the six D-01 types and NS/SOA rows render read-only. Enforcement remains the server-side 400 (plan 24-03/24-05); checkpoint step 6 verifies the API rejects NS directly, not merely that the button is hidden. |
| T-24-26 | Repudiation | operator mistaking the delete confirmation for an approval gate | mitigate | The confirmation is documented in code as a misclick guard only, and D-03's no-approval-gate constraint is asserted by grep in Task 2's acceptance criteria, so the behavior cannot silently drift into a partial approval workflow. |
| T-24-05 | Tampering / Spoofing | semantically malicious record values submitted by an authorized admin | accept | Final carry-forward of the D-03 acceptance recorded in plan 24-01's must_haves. No pre-write semantic analysis is performed at any layer. Post-hoc controls verified live in checkpoint steps 5, 6, and 8. |
| </threat_model> |
<success_criteria>
/admin/sync/route53renders zones, records, per-record history with source badges, and the schedule editor- An admin can create, update, and delete a record end-to-end and see it reflected in AWS
- History distinguishes
pulse_crudfromsync_detected_drift - NS/SOA are absent from the UI type selector and rejected with 400 by the API
- Delete executes immediately behind a single misclick guard (D-03), with no approval state
- Disabling Route 53 in
/admin/integrationssuppresses the health row without stopping sync (D-10) </success_criteria>