docs(24): create phase plan — 7 plans in 4 waves for AWS Route 53 DNS sync

This commit is contained in:
lorentz 2026-08-05 19:09:15 -04:00
parent 6ad27fcbb6
commit 15e52280b5
9 changed files with 2462 additions and 27 deletions

View file

@ -677,6 +677,30 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
| 22. Approval UI (LiveLink) | v3.0 | 6/6 | Complete | 2026-07-16 |
| 23. Classification Disposition + Per-Client Automation Gate | v3.0 | 6/6 | Complete | 2026-07-17 |
### Phase 24: AWS Route 53 DNS Sync
**Goal:** Sync DNS zones/records from AWS Route 53 into Postgres, support full CRUD back to Route 53 from Pulse, track record-level changes over time, log every sync and CRUD operation for audit, and integrate into the existing per-system sync section (scheduler, admin UI, health checks) alongside Autotask/Datto RMM/Veeam. AWS credentials are resolved via BWS (Bitwarden Secrets Manager), not plaintext env vars.
**Requirements**: SC-1, SC-2, SC-3, SC-4, SC-5, SC-6 (the numbered Success Criteria below serve as this phase's requirement IDs — this project has no REQUIREMENTS.md)
**Depends on:** Phase 23
**Plans:** 7 plans in 4 waves
Plans:
- [ ] 24-01-PLAN.md — Foundation: AWS SDK install, migration 102 (zones/records/history/audit tables), shared types, credential factory, BWS + DNS-egress checkpoint *(wave 1)*
- [ ] 24-02-PLAN.md — Route53SyncService: zone/record mirror sync with pagination, soft-delete, and `sync_detected_drift` change history *(wave 2)*
- [ ] 24-03-PLAN.md — Record validation (D-01 NS/SOA allowlist, AWS error sanitizer) + pending/committed/failed audit lifecycle persistence *(wave 2)*
- [ ] 24-04-PLAN.md — Health check: auth probe + D-12 live NS-delegation comparison, registered in integration-health *(wave 2)*
- [ ] 24-05-PLAN.md — `/api/route53/*` read routes, sync trigger, and CRUD write routes with `requireAdmin()` gating *(wave 3)*
- [ ] 24-06-PLAN.md — Scheduler entries (`route53-incremental`, `route53-full`) + `/admin/sync` tile *(wave 3)*
- [ ] 24-07-PLAN.md — `/admin/sync/route53` detail page, record editor dialog, end-to-end phase verification *(wave 4)*
**Success Criteria:**
1. Route 53 hosted zones and records sync into Postgres on a schedule, matching AWS as source of truth
2. Create/update/delete operations initiated from Pulse propagate to Route 53 via the AWS API
3. Every sync and CRUD operation is logged with actor, timestamp, and before/after values
4. Record-level change history is queryable (not just current state)
5. AWS credentials are resolved via BWS at runtime — never persisted in plaintext env vars
6. Integration appears in the existing sync admin UI/scheduler alongside other integrations
---
*Roadmap created: 2026-05-03*
*v2.0 phases added: 2026-07-10*

View file

@ -0,0 +1,381 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- package-lock.json
- migrations/102_route53_tables.sql
- lib/types/route53.ts
- lib/services/route53-factory.ts
- lib/services/route53-factory.test.ts
- CLAUDE.md
autonomous: false
requirements: [SC-3, SC-4, SC-5]
user_setup:
- service: aws-route53
why: "Route 53 API access for zone/record sync and CRUD write-back"
env_vars:
- name: AWS_ACCESS_KEY_ID
source: "Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID (injected by docker-entrypoint.sh via `bws run`) — NOT the committed .env file"
- name: AWS_SECRET_ACCESS_KEY
source: "Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID"
- name: AWS_REGION
source: "Bitwarden Secrets Manager project, or leave unset to default to us-east-1"
dashboard_config:
- task: "Create/confirm an IAM user or role scoped to route53:ListHostedZones, route53:GetHostedZone, route53:ListResourceRecordSets, route53:ChangeResourceRecordSets, route53:GetChange only (least privilege — T-24-08)"
location: "AWS Console -> IAM -> Users/Roles -> Permissions"
- task: "Confirm the BWS project emits the secrets under the literal key names AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION"
location: "Bitwarden Secrets Manager -> project referenced by BWS_PROJECT_ID"
must_haves:
truths:
- "SC-5: AWS credentials reach the Node process only as env vars injected by `bws run` at the docker entrypoint; no AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY value is added to the committed .env file"
- "SC-5: isRoute53Configured() returns false when AWS credential env vars are absent, and getRoute53Client() throws rather than constructing an unauthenticated client"
- "SC-3/SC-4: The dedicated Route 53 schema (zones, records, record history, audit log) exists in Postgres with a source tag column distinguishing pulse_crud from sync_detected_drift (D-06) and a status column supporting pending/committed/failed (D-07)"
- "D-10: An integration_settings row with key='route53' exists so the /admin/integrations toggle is display-only, with no sync/CRUD-blocking behavior anywhere"
- "D-03 ACCEPTED TRADEOFF: destructive record operations execute immediately with no staged approval gate. Malicious or malformed record values (dangling-CNAME / subdomain-takeover, SPF/DKIM TXT tampering) are NOT blocked pre-write. Mitigation is post-hoc traceability only — route53_audit_log captures actor, timestamp, and before/after for every attempt. This is an intentional, documented acceptance, not an oversight."
artifacts:
- path: "migrations/102_route53_tables.sql"
provides: "route53_zones / route53_records / route53_record_history / route53_audit_log + integration_settings seed"
contains: "CREATE TABLE IF NOT EXISTS route53_audit_log"
- path: "lib/services/route53-factory.ts"
provides: "getRoute53Client() + isRoute53Configured() + resetRoute53Client()"
exports: ["getRoute53Client", "isRoute53Configured", "resetRoute53Client"]
- path: "lib/types/route53.ts"
provides: "Route53Zone / Route53Record / Route53RecordHistory / Route53AuditLog / Route53SyncResult types"
- path: "lib/services/route53-factory.test.ts"
provides: "isRoute53Configured() branch coverage"
key_links:
- from: "lib/services/route53-factory.ts"
to: "@aws-sdk/client-route-53"
via: "Route53Client construction with no explicit credentials option"
pattern: "new Route53Client\\("
- from: "migrations/102_route53_tables.sql"
to: "integration_settings"
via: "seed row for key='route53'"
pattern: "INSERT INTO integration_settings"
---
<objective>
Lay the Route 53 foundation: install the official AWS SDK client, create the dedicated
Postgres schema (mirror tables + change-history ledger + audit ledger), define shared
TypeScript types, and add the credential factory following the exact
`lib/services/<name>-factory.ts` + `is<Name>Configured()` shape every other Pulse
integration uses.
Purpose: every downstream plan in this phase (sync service, CRUD routes, health check,
admin UI) imports from these four artifacts. Nothing else can start until they exist.
Output: `@aws-sdk/client-route-53` in package.json, `migrations/102_route53_tables.sql`,
`lib/types/route53.ts`, `lib/services/route53-factory.ts` (+ test), CLAUDE.md env-prefix row.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-CONTEXT.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
<interfaces>
<!-- Contracts downstream plans (24-02 through 24-07) will import. Define these exactly. -->
lib/services/route53-factory.ts exports:
isRoute53Configured(): boolean
getRoute53Client(): Route53Client // from '@aws-sdk/client-route-53'
resetRoute53Client(): void
lib/types/route53.ts exports (camelCase — API-response shape, transformed from snake_case rows):
Route53Zone { id, name, comment, privateZone, recordCount, authoritativeNameServers, syncedAt, isDeleted }
Route53Record { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt, isDeleted }
Route53RecordHistory { id, zoneId, recordKey, recordName, recordType, changeAction, beforeValue, afterValue, source, changedByUserId, changedByEmail, changedAt }
Route53AuditLog { id, operation, zoneId, recordKey, recordName, recordType, beforeValue, afterValue, performedByUserId, performedByEmail, performedAt, completedAt, status, awsChangeId, awsChangeStatus, errorMessage }
Route53RecordValue { value: string }
Route53WritableType 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'
Route53HistorySource 'pulse_crud' | 'sync_detected_drift'
Route53AuditStatus 'pending' | 'committed' | 'failed'
Route53SyncResult { syncId, syncType, status, startedAt, completedAt, duration, entities, errors }
Postgres primary keys (used by every downstream query):
route53_zones.id = AWS hosted zone id with the '/hostedzone/' prefix stripped
route53_records.record_key = `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install AWS SDK client and create the Route 53 migration</name>
<files>package.json, package-lock.json, migrations/102_route53_tables.sql</files>
<read_first>
- package.json (confirm no existing @aws-sdk dependency, confirm scripts)
- migrations/091_pax8_tables.sql (mirror-table conventions: raw_payload JSONB, synced_at/is_deleted/deleted_at, per-table idx_*_is_deleted)
- migrations/075_itglue_audit.sql (itglue_writes ledger shape: status CHECK, performed_by_user_id FK to "user"(id) ON DELETE SET NULL, before_value/after_value JSONB, error_message)
- migrations/081_integration_settings.sql (integration_settings columns + ON CONFLICT (key) DO NOTHING seed pattern)
- migrations/001_initial_schema.sql lines 542-555 (sync_history table — entity_type/sync_type/status/records_* columns the sync service will reuse; do NOT recreate it)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (migration section)
</read_first>
<action>
Run `npm install @aws-sdk/client-route-53`. The package legitimacy audit in 24-RESEARCH.md
already recorded a `[OK]` slopcheck verdict (official `aws/aws-sdk-js-v3` repo, ~1.78M
weekly downloads) — no additional legitimacy gate is required. Do NOT install
`@aws-sdk/credential-provider-node` or any `@smithy/*` package explicitly; they arrive
transitively and the default credential chain is used implicitly.
Create `migrations/102_route53_tables.sql` (next number after the current highest,
`101_reschedule_mimecast_sync.sql`). Every statement uses `IF NOT EXISTS`. Open with a
header comment block matching `migrations/091_pax8_tables.sql`'s style, stating that this
is the Phase 24 AWS Route 53 schema and that retention is unbounded by design (D-08 — no
purge job, matching existing Pulse convention).
Table `route53_zones`:
`id TEXT PRIMARY KEY` (AWS hosted zone id, `/hostedzone/` prefix stripped),
`name TEXT NOT NULL`, `comment TEXT`, `private_zone BOOLEAN NOT NULL DEFAULT false`,
`record_count INTEGER NOT NULL DEFAULT 0`,
`authoritative_name_servers JSONB` (the `DelegationSet.NameServers` array — consumed by the
D-12 NS-delegation health check in plan 24-04), `raw_payload JSONB`,
`created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`is_deleted BOOLEAN NOT NULL DEFAULT false`, `deleted_at TIMESTAMPTZ`.
Indexes: `idx_route53_zones_is_deleted` on `(is_deleted)`, `idx_route53_zones_name` on `(name)`.
Table `route53_records`:
`record_key TEXT PRIMARY KEY` (composite string `zoneId:name:type:setIdentifier`, empty
string for a null set identifier — Route 53 recordsets are uniquely identified by
zone+name+type+SetIdentifier, there is no AWS-side record id),
`zone_id TEXT NOT NULL REFERENCES route53_zones(id) ON DELETE CASCADE`,
`name TEXT NOT NULL`, `type TEXT NOT NULL`, `set_identifier TEXT`, `ttl INTEGER`,
`resource_records JSONB` (array of `{ "value": "..." }` objects),
`alias_target JSONB` (Route 53 alias records have no TTL/ResourceRecords), `raw_payload JSONB`,
plus the same five audit columns as `route53_zones`.
Indexes: `idx_route53_records_zone` on `(zone_id)`, `idx_route53_records_is_deleted` on
`(is_deleted)`, `idx_route53_records_name_type` on `(zone_id, name, type)`.
Table `route53_record_history` (D-06 — append-only change ledger, written by BOTH the sync
service on detected drift and the CRUD routes):
`id UUID PRIMARY KEY DEFAULT gen_random_uuid()`,
`zone_id TEXT NOT NULL REFERENCES route53_zones(id) ON DELETE CASCADE`,
`record_key TEXT NOT NULL`, `record_name TEXT NOT NULL`, `record_type TEXT NOT NULL`,
`change_action TEXT NOT NULL CHECK (change_action IN ('create','update','delete'))`,
`before_value JSONB`, `after_value JSONB`,
`source TEXT NOT NULL CHECK (source IN ('pulse_crud','sync_detected_drift'))`,
`changed_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL`,
`changed_by_email TEXT`, `audit_log_id UUID` (soft ref to `route53_audit_log(id)` — no hard
FK, so a history row survives audit-log changes), `changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`.
Indexes: `idx_route53_record_history_record` on `(record_key, changed_at DESC)`,
`idx_route53_record_history_source` on `(source)`,
`idx_route53_record_history_zone` on `(zone_id, changed_at DESC)`.
Table `route53_audit_log` (D-03/D-07 — every attempted operation including failures):
`id UUID PRIMARY KEY DEFAULT gen_random_uuid()`,
`operation TEXT NOT NULL CHECK (operation IN ('create','update','delete','sync'))`,
`zone_id TEXT`, `record_key TEXT`, `record_name TEXT`, `record_type TEXT`,
`before_value JSONB`, `after_value JSONB`,
`performed_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL`,
`performed_by_email TEXT`, `performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
`completed_at TIMESTAMPTZ`,
`status TEXT NOT NULL CHECK (status IN ('pending','committed','failed'))`,
`aws_change_id TEXT`, `aws_change_status TEXT`, `aws_response JSONB`, `error_message TEXT`.
Indexes: `ix_route53_audit_log_record` on `(zone_id, record_key, performed_at DESC)`,
`ix_route53_audit_log_status` on `(status)`.
`zone_id` is deliberately NOT an FK here — a failed attempt against a zone that was never
synced must still be recordable.
Finally append the D-10 seed row:
`INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING;`
Do NOT edit `migrations/081_integration_settings.sql` — it is committed.
Apply the migration to the running database manually (Postgres init only applies
`migrations/*.sql` on first volume boot — per CLAUDE.md and the MEMORY caveat confirmed by
migration 090): `docker exec -i pulse-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < migrations/102_route53_tables.sql`.
If the container is not running, note this in the SUMMARY as a deployment follow-up rather
than skipping the file.
</action>
<verify>
<automated>grep -c 'CREATE TABLE IF NOT EXISTS route53_' migrations/102_route53_tables.sql | grep -qx 4 && grep -q "source IN ('pulse_crud','sync_detected_drift')" migrations/102_route53_tables.sql && grep -q "status IN ('pending','committed','failed')" migrations/102_route53_tables.sql && grep -q "INSERT INTO integration_settings" migrations/102_route53_tables.sql && node -e "const p=require('./package.json');if(!p.dependencies['@aws-sdk/client-route-53'])process.exit(1)" && echo PASS</automated>
</verify>
<acceptance_criteria>
- `package.json` `dependencies` contains a `@aws-sdk/client-route-53` entry; `package-lock.json` is updated in the same commit
- `migrations/102_route53_tables.sql` exists and contains exactly 4 `CREATE TABLE IF NOT EXISTS route53_*` statements
- `route53_record_history` has a `source TEXT NOT NULL CHECK (source IN ('pulse_crud','sync_detected_drift'))` column (D-06)
- `route53_audit_log` has `status TEXT NOT NULL CHECK (status IN ('pending','committed','failed'))` and an `error_message TEXT` column (D-07)
- `route53_zones` has an `authoritative_name_servers JSONB` column (D-12 input)
- The file ends with `INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING;` (D-10)
- `git diff --name-only` does NOT list `migrations/081_integration_settings.sql` or any other pre-existing migration
- No `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY` line appears in the committed `.env` file: `grep -c '^AWS_' .env` returns 0
</acceptance_criteria>
<done>Migration file created with all 4 tables + seed row; AWS SDK installed; no committed migration edited; no AWS secret written to .env.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add Route 53 shared types and the credential factory</name>
<files>lib/types/route53.ts, lib/services/route53-factory.ts, lib/services/route53-factory.test.ts, CLAUDE.md</files>
<read_first>
- lib/services/veeam-factory.ts (canonical factory shape — singleton, isXConfigured, throw-on-missing, resetXClient)
- lib/services/datto-rmm-factory.ts (multi-var config check variant)
- lib/services/pax8-factory.test.ts (existing factory test conventions in this codebase — env var save/restore pattern)
- lib/types/veeam.ts (domain type barrel conventions)
- migrations/102_route53_tables.sql (column names the types must mirror in camelCase)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Pattern 1 and Pitfall 1)
</read_first>
<behavior>
- `isRoute53Configured()` returns `false` when `AWS_ACCESS_KEY_ID` is unset
- `isRoute53Configured()` returns `false` when `AWS_SECRET_ACCESS_KEY` is unset
- `isRoute53Configured()` returns `false` when both are set to empty strings
- `isRoute53Configured()` returns `true` when both are set to non-empty values
- `getRoute53Client()` throws an Error mentioning `AWS_ACCESS_KEY_ID` when credentials are absent
- `getRoute53Client()` returns the same instance on a second call (singleton), and a different instance after `resetRoute53Client()`
</behavior>
<action>
Create `lib/types/route53.ts` exporting the camelCase interfaces and string-literal unions
listed in this plan's `<interfaces>` block. These are the API-response shapes — route
handlers transform `snake_case` rows into them manually (no ORM, per CLAUDE.md). Include
`Route53WritableType` as `'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'` (D-01) and
`Route53HistorySource` / `Route53AuditStatus` matching the migration's CHECK constraints
exactly. `Route53SyncResult` mirrors the shape `VeeamSyncResult` uses in
`lib/services/veeam-sync-service.ts` (`syncId`, `syncType`, `status`, `startedAt`,
`completedAt`, `duration`, `entities`, `errors`).
Create `lib/services/route53-factory.ts` following `lib/services/veeam-factory.ts` line for
line, adapted per 24-PATTERNS.md's "Adaptation for Route 53" snippet:
module-level `let route53ClientInstance: Route53Client | null = null`;
`isRoute53Configured()` returning `!!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)`;
`getRoute53Client()` that throws with the message
`'AWS credentials missing. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_REGION) environment variables.'`
when unconfigured, otherwise constructs
`new Route53Client({ region: process.env.AWS_REGION || 'us-east-1' })`;
and `resetRoute53Client()` setting the singleton back to null.
CRITICAL (24-RESEARCH.md Pitfall 1): do NOT pass an explicit `credentials:` option to
`Route53Client`. Omitting it lets `@aws-sdk/credential-provider-node`'s default chain read
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` from `process.env`,
which is exactly how BWS injects them at the `docker-entrypoint.sh` layer. Add an inline
comment stating this so a future reader does not "fix" it by adding explicit credentials.
Do NOT introduce a `ROUTE53_*` env prefix — the SDK hardcodes the `AWS_*` names.
Create `lib/services/route53-factory.test.ts` covering the `<behavior>` cases above. Follow
`lib/services/pax8-factory.test.ts`'s env-var save/restore discipline (snapshot
`process.env` values in `beforeEach`, restore in `afterEach`) and call
`resetRoute53Client()` between cases so the singleton does not leak across tests. Import
`describe`/`it`/`expect`/`beforeEach`/`afterEach` explicitly from `vitest` (this project
sets `globals: false` in `vitest.config.ts`).
Add a row to CLAUDE.md's integration env-prefix table: `| AWS Route 53 | AWS_* (literal
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION — intentional exception to the
per-service prefix convention; the AWS SDK's default credential chain hardcodes these
names. Injected by BWS at the container entrypoint, never in .env) |`.
</action>
<verify>
<automated>npx vitest run lib/services/route53-factory.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `npx vitest run lib/services/route53-factory.test.ts` passes with at least 5 assertions covering the `<behavior>` list
- `npx tsc --noEmit --pretty` exits 0
- `grep -n "credentials" lib/services/route53-factory.ts` shows only comment lines, never a `credentials:` object literal passed to `Route53Client`
- `grep -c 'ROUTE53_ACCESS\|ROUTE53_SECRET' lib/services/route53-factory.ts` returns 0
- `lib/types/route53.ts` exports `Route53WritableType` with exactly the six D-01 types and no `NS` or `SOA` member: `grep -q "'SRV'" lib/types/route53.ts && ! grep -q "'NS'" lib/types/route53.ts`
- CLAUDE.md's integration table contains a row matching `grep -c 'AWS Route 53' CLAUDE.md` >= 1
</acceptance_criteria>
<done>Types and factory exist, factory tests green, type-check clean, no explicit credentials wiring, CLAUDE.md documents the AWS_* exception.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Confirm BWS credential names and outbound DNS egress</name>
<action>
Pause execution and present the four verification steps below to the developer verbatim. Run any command the developer asks you to run on their behalf, but do not proceed to plan 24-02 until they respond. Record every answer in the SUMMARY — plan 24-04's resolver implementation branches on the DNS-egress result, and plan 24-01 Task 2's factory may need a one-line env var name change if the BWS key names differ.
</action>
<what-built>
The AWS SDK is installed, the Route 53 schema exists in Postgres, and
`lib/services/route53-factory.ts` reads credentials from the literal `AWS_ACCESS_KEY_ID`
/ `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` env var names via the AWS SDK's default
credential provider chain. Three assumptions from 24-RESEARCH.md's Open Questions cannot
be verified from the repository and must be confirmed before plans 24-02 through 24-07
build on them.
</what-built>
<how-to-verify>
1. **BWS secret key names (Open Question 1, Assumption A2).** In Bitwarden Secrets Manager,
open the project referenced by `BWS_PROJECT_ID` and confirm the AWS credentials are
stored under keys named exactly `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
(optionally) `AWS_REGION`. `bws run` exports each secret under its own key name, so a
different key name means the factory will not see the credentials.
If the names differ, report the actual names — the factory in Task 2 needs a one-line change.
2. **Credentials reach the container (Open Question 2).** With the app running, run:
`docker exec pulse-app sh -lc 'echo "id=${AWS_ACCESS_KEY_ID:+SET} secret=${AWS_SECRET_ACCESS_KEY:+SET} region=${AWS_REGION:-unset}"'`
Expected: `id=SET secret=SET region=us-east-1` (or another explicit region).
This prints only presence markers, never the secret values.
3. **Outbound DNS egress to public resolvers (Open Question 3, Assumption A3).** The D-12
NS-delegation health check in plan 24-04 depends on reaching 1.1.1.1/8.8.8.8 on UDP/53
from inside the container. Run:
`docker exec pulse-app node -e "const{Resolver}=require('dns');const r=new Resolver();r.setServers(['1.1.1.1','8.8.8.8']);r.resolveNs('google.com',(e,a)=>console.log(e?'EGRESS-BLOCKED: '+e.code:'EGRESS-OK: '+a.join(',')))"`
Expected: a line starting `EGRESS-OK:`. If it prints `EGRESS-BLOCKED`, plan 24-04 must
use the DoH-over-HTTPS fallback described in 24-RESEARCH.md's Alternatives Considered
instead of Node's `dns` module.
4. **IAM scope (T-24-08, operational).** Confirm the IAM principal behind these credentials
is scoped to Route 53 actions only (`route53:ListHostedZones`, `route53:GetHostedZone`,
`route53:ListResourceRecordSets`, `route53:ChangeResourceRecordSets`, `route53:GetChange`).
This is an AWS-console concern Pulse cannot enforce; report the answer either way.
</how-to-verify>
<resume-signal>
Reply with the four answers, e.g. "1: names match / 2: SET SET us-east-1 / 3: EGRESS-OK /
4: scoped to route53 only", or describe any deviation. Type "approved" if all four match
expectations.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| BWS/Bitwarden cloud → container env | AWS credentials cross into the process at `docker-entrypoint.sh` before Node starts |
| Node process → AWS Route 53 API | SigV4-signed HTTPS calls carrying long-lived IAM credentials |
| npm registry → repo dependency tree | New third-party package added to production runtime |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-06 | Information Disclosure | committed `.env` file | mitigate | AWS credentials are NEVER written to `.env` (which is committed to git per CLAUDE.md). Task 1 acceptance criterion asserts `grep -c '^AWS_' .env` returns 0. Credentials arrive only via `bws run` env injection. |
| T-24-08 | Elevation of Privilege | IAM principal behind AWS_ACCESS_KEY_ID | transfer | Least-privilege IAM policy scoped to the five Route 53 actions. Enforced in the AWS console, not in Pulse code — surfaced as checkpoint question 4 and recorded in `user_setup`. |
| T-24-09 | Information Disclosure | checkpoint verification commands | mitigate | Verification steps print only `SET`/`unset` presence markers (`${VAR:+SET}`), never secret values. |
| T-24-SC | Tampering | `npm install @aws-sdk/client-route-53` | mitigate | 24-RESEARCH.md `## Package Legitimacy Audit` records a `[OK]` slopcheck verdict for the single new package (official `aws/aws-sdk-js-v3` repo, ~6 years old, ~1.78M weekly downloads). No `[ASSUMED]`/`[SUS]` packages in this phase, so no blocking legitimacy checkpoint is required. Version is recorded in `package-lock.json`. |
| T-24-05 | Tampering / Spoofing | record values written to live DNS (dangling CNAME, SPF/DKIM TXT tampering) | accept | D-03 explicitly accepts immediate execution with no pre-write approval gate. Compensating control is post-hoc only: `route53_audit_log` captures actor, timestamp, and before/after for every attempt including failures. Recorded verbatim in this plan's `must_haves.truths` as an intentional acceptance. |
</threat_model>
<verification>
- `npx vitest run lib/services/route53-factory.test.ts` green
- `npx tsc --noEmit --pretty` exits 0
- `npm test` (full suite) still green — no regression from the new dependency
- `psql -c "\d route53_audit_log"` (or `docker exec pulse-postgres psql ... -c '\dt route53_*'`) lists all four tables
- Checkpoint answers recorded in the SUMMARY, including whether DNS egress is available (drives plan 24-04's implementation choice)
</verification>
<success_criteria>
- `@aws-sdk/client-route-53` present in `package.json` dependencies
- `migrations/102_route53_tables.sql` defines route53_zones, route53_records, route53_record_history, route53_audit_log and seeds `integration_settings('route53')`
- `lib/types/route53.ts` and `lib/services/route53-factory.ts` export the contracts listed in `<interfaces>`
- Factory tests pass; type-check clean
- No AWS secret value written to `.env` or any committed file
- Checkpoint answered: BWS key names confirmed, credentials present in container, DNS egress result known
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md` when done.
Record in the SUMMARY: the confirmed BWS secret key names, the DNS-egress result
(EGRESS-OK vs EGRESS-BLOCKED — plan 24-04 depends on this), and whether the migration was
applied to the live database or is pending deployment.
</output>

View file

@ -0,0 +1,371 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 02
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
- lib/services/route53-record-key.ts
- lib/services/route53-record-key.test.ts
- lib/services/route53-sync-service.ts
- lib/services/route53-sync-service.test.ts
autonomous: true
requirements: [SC-1, SC-4]
must_haves:
truths:
- "SC-1: A scheduled sync pulls every hosted zone and every resource record set from AWS Route 53 into route53_zones / route53_records, with AWS as the source of truth"
- "SC-1: Records that disappear from AWS are soft-deleted in the mirror (is_deleted=true, deleted_at set) rather than left stale"
- "SC-4: D-06 — when a synced record differs from the mirror row, the sync writes a route53_record_history row tagged source='sync_detected_drift', so 'did someone change this outside Pulse?' is answerable from the ledger"
- "SC-4: Drift history rows carry the whole-recordset before/after snapshot (Route 53 models an update as a whole-recordset replace, so per-value diffing is not attempted)"
- "A sync run is bookkept in the existing sync_history table with entity_type='route53', and a catastrophic failure marks that row status='failed' with the error message"
artifacts:
- path: "lib/services/route53-record-key.ts"
provides: "Pure record-key derivation, recordset normalization, and drift classification helpers (unit-testable without AWS)"
exports: ["buildRecordKey", "normalizeRecordSet", "classifyDrift", "recordSetsEqual"]
- path: "lib/services/route53-sync-service.ts"
provides: "Route53SyncService with fullSync/incrementalSync + getRoute53SyncService() singleton"
exports: ["Route53SyncService", "getRoute53SyncService"]
min_lines: 200
- path: "lib/services/route53-sync-service.test.ts"
provides: "Drift-classification and history-row-shape coverage"
key_links:
- from: "lib/services/route53-sync-service.ts"
to: "lib/services/route53-factory.ts"
via: "getRoute53Client() import"
pattern: "getRoute53Client"
- from: "lib/services/route53-sync-service.ts"
to: "route53_record_history"
via: "INSERT with source='sync_detected_drift'"
pattern: "sync_detected_drift"
- from: "lib/services/route53-sync-service.ts"
to: "sync_history"
via: "INSERT/UPDATE bookkeeping with entity_type='route53'"
pattern: "sync_history"
---
<objective>
Build the Route 53 → Postgres mirror sync: paginate hosted zones and resource record sets
from AWS, upsert them into the Phase 24 tables, soft-delete anything AWS no longer returns,
and append a `sync_detected_drift` history row for every record whose content changed
outside Pulse (D-06).
Purpose: SC-1 (scheduled sync with AWS as source of truth) and the drift half of SC-4
(queryable record-level history, not just current state).
Output: `lib/services/route53-record-key.ts` (pure helpers + tests),
`lib/services/route53-sync-service.ts` (+ tests), exporting `getRoute53SyncService()` for
plans 24-05 and 24-06 to consume.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-01-SUMMARY.md
<interfaces>
<!-- From plan 24-01. Use directly; do not re-derive by exploring the codebase. -->
lib/services/route53-factory.ts:
isRoute53Configured(): boolean
getRoute53Client(): Route53Client
resetRoute53Client(): void
lib/types/route53.ts:
Route53Record { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt, isDeleted }
Route53SyncResult { syncId, syncType, status, startedAt, completedAt, duration, entities, errors }
Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'
Postgres (migration 102):
route53_zones(id TEXT PK, name, comment, private_zone, record_count,
authoritative_name_servers JSONB, raw_payload JSONB,
created_at, updated_at, synced_at, is_deleted, deleted_at)
route53_records(record_key TEXT PK, zone_id TEXT FK->route53_zones(id) ON DELETE CASCADE,
name, type, set_identifier, ttl, resource_records JSONB,
alias_target JSONB, raw_payload JSONB,
created_at, updated_at, synced_at, is_deleted, deleted_at)
route53_record_history(id UUID PK, zone_id, record_key, record_name, record_type,
change_action CHECK IN ('create','update','delete'),
before_value JSONB, after_value JSONB,
source CHECK IN ('pulse_crud','sync_detected_drift'),
changed_by_user_id, changed_by_email, audit_log_id, changed_at)
Pre-existing (migration 001, do not alter):
sync_history(id SERIAL PK, entity_type VARCHAR(100), sync_type VARCHAR(50)
CHECK IN ('full','incremental','entity-specific'),
status CHECK IN ('started','in_progress','completed','failed'),
started_at, completed_at, records_added, records_updated,
records_deleted, error_message, triggered_by, entity_details)
lib/services/postgres-client.ts default export `postgresClient`:
.query<T>(sql, params?) -> { rows: T[] }
.transaction(fn)
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Pure record-key, normalization, and drift-classification helpers</name>
<files>lib/services/route53-record-key.ts, lib/services/route53-record-key.test.ts</files>
<read_first>
- lib/types/route53.ts (types created in plan 24-01)
- lib/services/analyzer/link-discovery.ts (the `_INTERNALS` export convention used in this codebase for testing private helpers)
- lib/services/pax8-company-matcher.test.ts (existing pure-helper test style: explicit vitest imports, table-driven cases)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Anti-Patterns: no per-value diffing; whole-recordset replace)
</read_first>
<behavior>
- `buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: null })` returns `'Z123:www.example.com.:A:'`
- `buildRecordKey` with a non-null `setIdentifier` appends it after the final colon
- `normalizeRecordSet` lowercases `name`, preserves the trailing dot, uppercases `type`, coerces missing `TTL` to `null`, and maps `ResourceRecords` to a sorted array of `{ value }` objects so ordering differences do not register as drift
- `normalizeRecordSet` on an alias record (no `TTL`, no `ResourceRecords`, has `AliasTarget`) returns `ttl: null`, `resourceRecords: []`, and a populated `aliasTarget`
- `recordSetsEqual(a, a)` is `true`; changing a single `ResourceRecords` value makes it `false`; changing only the order of `ResourceRecords` keeps it `true`
- `recordSetsEqual` returns `false` when TTL differs
- `classifyDrift(null, next)` returns `'create'`
- `classifyDrift(prev, null)` returns `'delete'`
- `classifyDrift(prev, next)` returns `'update'` when the normalized sets differ
- `classifyDrift(prev, next)` returns `null` (no history row) when the normalized sets are equal
</behavior>
<action>
Create `lib/services/route53-record-key.ts` — a dependency-free module (no `pg`, no AWS SDK
client construction; it may import types from `@aws-sdk/client-route-53` and
`@/lib/types/route53`) so it is unit-testable without mocking anything.
Export:
- `buildRecordKey(input: { zoneId: string; name: string; type: string; setIdentifier?: string | null }): string`
producing `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`. This is the
`route53_records.record_key` primary key and the `recordId` URL segment used by plan
24-05's routes.
- `normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSet` where
`NormalizedRecordSet` is `{ recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget }`.
Normalization rules: `name` lowercased with its trailing dot preserved; `type` uppercased;
`ttl` is `rs.TTL ?? null`; `resourceRecords` is `(rs.ResourceRecords ?? []).map(r => ({ value: r.Value })).sort((a,b) => a.value.localeCompare(b.value))`;
`aliasTarget` is `rs.AliasTarget ?? null`; `setIdentifier` is `rs.SetIdentifier ?? null`.
Sorting matters: AWS does not guarantee value ordering, and unsorted comparison would
produce phantom drift history rows on every sync.
- `recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): boolean`
comparing `ttl`, the serialized `resourceRecords` array, and the serialized `aliasTarget`.
Both null returns true; one null returns false.
- `classifyDrift(prev: NormalizedRecordSet | null, next: NormalizedRecordSet | null): 'create' | 'update' | 'delete' | null`
mapping to `route53_record_history.change_action`, returning `null` when there is no
material difference so the sync does not write a no-op history row.
- `toHistoryPayload(ns: NormalizedRecordSet | null): unknown` returning the JSONB shape
stored in `before_value`/`after_value`: `null` for a null input, otherwise
`{ name, type, setIdentifier, ttl, resourceRecords, aliasTarget }` — the whole-recordset
snapshot (24-RESEARCH.md Anti-Patterns: Route 53 has no partial-value primitive, so the
history unit of change is the whole recordset).
Do not implement per-value diffing anywhere.
Create `lib/services/route53-record-key.test.ts` covering every `<behavior>` case above.
Import `describe`/`it`/`expect` explicitly from `vitest` (`globals: false` in
`vitest.config.ts`).
</action>
<verify>
<automated>npx vitest run lib/services/route53-record-key.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `npx vitest run lib/services/route53-record-key.test.ts` passes with at least 10 assertions covering every `<behavior>` bullet
- `grep -c "from 'pg'\|postgres-client\|Route53Client" lib/services/route53-record-key.ts` returns 0 — the module has no runtime dependency on a DB pool or an AWS client
- `classifyDrift` returns `null` for equal recordsets (asserted in the test file), preventing no-op history rows
- Re-ordering `resourceRecords` does not change `recordSetsEqual`'s result (asserted in the test file)
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>Pure helpers exist with full unit coverage; no phantom-drift ordering bug; type-check clean.</done>
</task>
<task type="auto">
<name>Task 2: Route53SyncService — zones and records mirror sync</name>
<files>lib/services/route53-sync-service.ts</files>
<read_first>
- lib/services/veeam-sync-service.ts (class shape, isSyncInProgress, executeSync, sync_history bookkeeping at lines ~78-90 and ~139-165, step-loop with per-step error isolation at lines ~94-120, upsert-with-FK-safety-set at lines ~219-247)
- lib/services/pax8-sync-service.ts (getPax8SyncService() singleton export shape at line ~689)
- lib/services/route53-record-key.ts (created in Task 1)
- lib/services/route53-factory.ts (created in plan 24-01)
- lib/services/postgres-client.ts (query/transaction signatures)
- migrations/102_route53_tables.sql (exact column names)
</read_first>
<action>
Create `lib/services/route53-sync-service.ts` following `lib/services/veeam-sync-service.ts`'s
class shape exactly:
- `export class Route53SyncService` with a private `client: Route53Client` (constructor
takes an optional client, defaulting to `getRoute53Client()`) and a private
`isSyncing = false` flag.
- `isSyncInProgress(): boolean`
- `fullSync(triggeredBy = 'system'): Promise<Route53SyncResult>``executeSync('full', triggeredBy)`
- `incrementalSync(triggeredBy = 'system'): Promise<Route53SyncResult>``executeSync('incremental', triggeredBy)`
- private `executeSync(syncType, triggeredBy)` that throws
`'A Route 53 sync operation is already in progress'` when `isSyncing`, sets the flag,
builds `syncId = 'route53-' + Date.now()`, inserts the `sync_history` row
(`entity_type='route53'`, `sync_type` = the literal `'full'`/`'incremental'` — the table's
CHECK constraint only allows `full`/`incremental`/`entity-specific`, so do NOT write
`route53-full` there), runs the step loop, updates `sync_history` on completion, and
resets `isSyncing` in a `finally` block.
- Step array in order: `{ name: 'zones', fn: () => this.syncZones() }` then
`{ name: 'records', fn: () => this.syncRecords() }`. Zones must run first because
`route53_records.zone_id` has an FK to `route53_zones(id)`.
- Wrap each step in its own try/catch so one failing step does not abort the other, pushing
`{ entity, success, recordsUpserted, duration, error }` into `entityResults` — same shape
as `VeeamSyncService`.
- Catastrophic-failure catch block updates `sync_history` to `status='failed'` with
`error_message`, matching the Veeam analog's lines ~152-173. Log with a `[ROUTE53-SYNC]`
prefix. Log `error.message` only — never the full AWS SDK error object, which can carry
request headers (T-24-03).
`syncZones()`: paginate `ListHostedZonesCommand` using `Marker` / `IsTruncated` /
`NextMarker`. For each zone strip the `/hostedzone/` prefix from `Id`. To populate
`authoritative_name_servers` (needed by plan 24-04's D-12 check), call
`GetHostedZoneCommand({ Id: zoneId })` per zone and store `DelegationSet?.NameServers ?? []`
as JSONB. Upsert with `INSERT INTO route53_zones (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`.
After the loop, soft-delete zones no longer returned by AWS:
`UPDATE route53_zones SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE is_deleted = false AND id <> ALL($1)`
using the collected id array. Return the upserted count.
`syncRecords()`: load the live zone id set with
`SELECT id FROM route53_zones WHERE is_deleted = false` (the FK-safety-set pattern from
`veeam-sync-service.ts` `syncBackupServers()`). For each zone, paginate
`ListResourceRecordSetsCommand` using `StartRecordName` / `StartRecordType` /
`StartRecordIdentifier` from `NextRecordName` / `NextRecordType` / `NextRecordIdentifier`
while `IsTruncated`. Normalize each recordset with `normalizeRecordSet()` and derive its key
with `buildRecordKey()`. Upsert into `route53_records` on `ON CONFLICT (record_key) DO UPDATE`,
resetting `is_deleted = false, deleted_at = NULL, synced_at = NOW(), updated_at = NOW()`.
After each zone's pagination completes, soft-delete that zone's records no longer present:
`UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE zone_id = $1 AND is_deleted = false AND record_key <> ALL($2)`.
Do NOT hard-delete — the history ledger references `record_key`.
Both `fullSync` and `incrementalSync` run the same two steps. Per D-11 the difference is
cadence, not scope: Route 53's list APIs expose no modification cursor, so an "incremental"
run is the same full read against AWS with the same diff, just scheduled more frequently.
Add a comment in `executeSync` stating this explicitly so a future reader does not assume a
missing incremental optimization is a bug.
Export a `getRoute53SyncService(): Route53SyncService` module-level singleton following
`pax8-sync-service.ts`'s pattern (lazy `let instance` + accessor). Plans 24-05 and 24-06
import this.
Do NOT add any `integration_settings.disabled` check in this file — per D-10 the Route 53
disable toggle is display-only and must never gate sync.
</action>
<verify>
<automated>npx tsc --noEmit --pretty && grep -q "getRoute53SyncService" lib/services/route53-sync-service.ts && grep -qv "integration_settings" lib/services/route53-sync-service.ts && echo PASS</automated>
</verify>
<acceptance_criteria>
- `lib/services/route53-sync-service.ts` exports `Route53SyncService` and `getRoute53SyncService`
- `npx tsc --noEmit --pretty` exits 0
- `grep -c 'integration_settings' lib/services/route53-sync-service.ts` returns 0 (D-10 — disable never gates sync)
- `grep -c "entity_type" lib/services/route53-sync-service.ts` >= 1 and the inserted `sync_type` value is the literal `'full'` or `'incremental'`, satisfying `sync_history`'s CHECK constraint
- Both `ListHostedZonesCommand` and `ListResourceRecordSetsCommand` pagination loops are present: `grep -c 'IsTruncated' lib/services/route53-sync-service.ts` >= 2
- Soft-delete statements exist for both tables: `grep -c 'is_deleted = true' lib/services/route53-sync-service.ts` >= 2
- No `console.error` call passes a raw error object: every logging site uses `error instanceof Error ? error.message : String(error)` (T-24-03)
</acceptance_criteria>
<done>Sync service mirrors zones and records with pagination, soft-delete, and sync_history bookkeeping; no disable gating; type-check clean.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Drift detection writes sync_detected_drift history rows</name>
<files>lib/services/route53-sync-service.ts, lib/services/route53-sync-service.test.ts</files>
<read_first>
- lib/services/route53-sync-service.ts (as written in Task 2)
- lib/services/route53-record-key.ts (classifyDrift, toHistoryPayload)
- migrations/102_route53_tables.sql (route53_record_history columns and CHECK constraints)
- lib/services/pax8-sync-service.test.ts (existing sync-service test style — how this codebase mocks postgresClient and external clients)
</read_first>
<behavior>
- Given a mirror row and an AWS recordset with a changed TTL, the drift step produces one history row with `change_action='update'`, `source='sync_detected_drift'`, `changed_by_user_id=null`
- Given a mirror row with no matching AWS recordset, the drift step produces a history row with `change_action='delete'` and a non-null `before_value`, null `after_value`
- Given an AWS recordset with no matching mirror row, the drift step produces a history row with `change_action='create'`, null `before_value`, non-null `after_value`
- Given identical mirror and AWS recordsets, the drift step produces zero history rows
- `before_value`/`after_value` payloads contain the whole recordset (`name`, `type`, `setIdentifier`, `ttl`, `resourceRecords`, `aliasTarget`), not a per-field delta
- A record changed by Pulse CRUD within the same window is still tagged `sync_detected_drift` by the sync (the sync has no way to know), and the `pulse_crud` row written by the CRUD route is the authoritative one — both rows coexist in the ledger
</behavior>
<action>
Extract the drift logic into an exported, injectable pure function in
`lib/services/route53-sync-service.ts` so it is unit-testable without a database:
`export function buildDriftHistoryRows(prevByKey: Map<string, NormalizedRecordSet>, nextByKey: Map<string, NormalizedRecordSet>, zoneId: string): DriftHistoryRow[]`
where `DriftHistoryRow` is
`{ zoneId, recordKey, recordName, recordType, changeAction, beforeValue, afterValue }`.
Implementation: union the two key sets, call `classifyDrift(prev, next)` per key, skip keys
returning `null`, and build the row using `toHistoryPayload()` for both values.
Wire it into `syncRecords()`: before upserting a zone's recordsets, load that zone's current
mirror rows (`SELECT record_key, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE zone_id = $1 AND is_deleted = false`)
and shape them into `NormalizedRecordSet`s. Compute `buildDriftHistoryRows(...)` BEFORE the
upsert (after the upsert the previous state is gone). Then upsert, then insert the drift
rows with
`INSERT INTO route53_record_history (zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email) VALUES (..., 'sync_detected_drift', NULL, NULL)`.
Insert drift rows in a batch (one multi-row INSERT or a loop inside
`postgresClient.transaction()`), and count them into the step's result so the sync summary
reports drift volume.
Guard the very first sync: if the mirror had zero rows for a zone, do NOT emit `create`
history rows for the entire zone (that would produce thousands of meaningless rows on
initial import). Detect this with a zone-level check — if `prevByKey.size === 0`, skip
history generation for that zone entirely and log
`[ROUTE53-SYNC] Initial import for zone <id> — skipping drift history` once.
Create `lib/services/route53-sync-service.test.ts` covering every `<behavior>` case by
calling `buildDriftHistoryRows` directly with hand-constructed maps. No AWS or Postgres
mocking is required for these cases. Import vitest primitives explicitly.
</action>
<verify>
<automated>npx vitest run lib/services/route53-sync-service.test.ts && npx tsc --noEmit --pretty && npm test</automated>
</verify>
<acceptance_criteria>
- `npx vitest run lib/services/route53-sync-service.test.ts` passes with cases for update, delete, create, and no-change
- `buildDriftHistoryRows` is exported from `lib/services/route53-sync-service.ts` and takes no database handle
- `grep -c "'sync_detected_drift'" lib/services/route53-sync-service.ts` >= 1
- The insert statement sets `changed_by_user_id` to NULL for drift rows (drift has no Pulse actor)
- Initial-import guard present: `grep -q "prevByKey.size === 0" lib/services/route53-sync-service.ts`
- Drift rows are computed before the upsert — the `buildDriftHistoryRows` call appears earlier in `syncRecords()` than the `INSERT INTO route53_records` statement
- `npm test` (full suite) exits 0
</acceptance_criteria>
<done>Drift produces correctly tagged history rows, initial import does not flood the ledger, full suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AWS Route 53 API → Pulse sync service | Untrusted-shape external payloads (zone/record data) enter Postgres |
| Sync service → application logs | AWS SDK errors may carry request metadata |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-03 | Information Disclosure | `console.error` in `Route53SyncService.executeSync` and step catch blocks | mitigate | Log `error instanceof Error ? error.message : String(error)` only; never pass the AWS SDK error object (which can include `$metadata` and request headers) to a logger. Asserted in Task 2 acceptance criteria. |
| T-24-04 | Repudiation | record changes made outside Pulse (AWS console, IaC) | mitigate | D-06 drift detection writes a `route53_record_history` row with `source='sync_detected_drift'` and whole-recordset before/after for every externally-changed record, making external mutation attributable-in-time even when the actor is unknown to Pulse. |
| T-24-10 | Denial of Service | initial import emitting one history row per record across all zones | mitigate | Zone-level initial-import guard (`prevByKey.size === 0` → skip history) prevents an unbounded first-run write amplification into the append-only, never-purged ledger (D-08). |
| T-24-11 | Tampering | AWS-supplied record values written directly into Postgres JSONB | accept | Values are stored as data and rendered as text by the admin UI (plan 24-07 renders through React's default escaping, no `dangerouslySetInnerHTML`). Route 53 is itself the authoritative source; validating its own output against itself provides no security benefit. |
| T-24-05 | Tampering / Spoofing | live DNS record content | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with post-hoc audit only. |
</threat_model>
<verification>
- `npx vitest run lib/services/route53-record-key.test.ts lib/services/route53-sync-service.test.ts` green
- `npm test` full suite green
- `npx tsc --noEmit --pretty` exits 0
- Manual smoke (if AWS credentials are live): `node -e "require('ts-node')"` is not available — instead trigger via plan 24-05's `/api/route53/sync` route once it exists, or confirm at the plan 24-07 checkpoint
</verification>
<success_criteria>
- Zones and records mirror into Postgres with pagination and soft-delete
- Drift produces `route53_record_history` rows tagged `sync_detected_drift` with whole-recordset before/after
- Equal recordsets produce zero history rows; initial import produces zero history rows
- `getRoute53SyncService()` exported for plans 24-05 and 24-06
- No `integration_settings` gating anywhere in this file (D-10)
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md` when done.
</output>

View file

@ -0,0 +1,295 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 03
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
- lib/services/route53-record-validation.ts
- lib/services/route53-record-validation.test.ts
- lib/services/route53-write-persistence.ts
- lib/services/route53-write-persistence.test.ts
autonomous: true
requirements: [SC-3]
must_haves:
truths:
- "D-01: A server-side validator rejects any write targeting record type NS or SOA, and accepts only A, AAAA, CNAME, MX, TXT, SRV — the rejection happens in library code the routes call before any AWS command is constructed, not in the UI"
- "SC-3/D-07: An audit row is created with status='pending' BEFORE any AWS call is made, and is transitioned to 'committed' or 'failed' after — no write to Route 53 can occur without an audit row already in flight"
- "SC-3/D-07: A failed AWS attempt leaves a route53_audit_log row with status='failed', a sanitized error_message, and the attempted before/after values — failures are as auditable as successes"
- "T-24-03: Error messages persisted and returned are sanitized — capped in length and stripped of AWS account ARNs, request ids, and access key ids before storage or client return"
artifacts:
- path: "lib/services/route53-record-validation.ts"
provides: "D-01 writable-type allowlist + record-shape validation, unit-testable without a request"
exports: ["WRITABLE_RECORD_TYPES", "validateRecordWrite", "sanitizeAwsError"]
- path: "lib/services/route53-write-persistence.ts"
provides: "pending/committed/failed audit lifecycle + pulse_crud history rows + mirror refresh"
exports: ["createPendingAuditLog", "markAuditCommitted", "markAuditFailed", "insertPulseCrudHistory", "upsertMirrorRecord", "softDeleteMirrorRecord"]
- path: "lib/services/route53-record-validation.test.ts"
provides: "allowlist and sanitizer coverage"
key_links:
- from: "lib/services/route53-record-validation.ts"
to: "lib/types/route53.ts"
via: "Route53WritableType import"
pattern: "Route53WritableType"
- from: "lib/services/route53-write-persistence.ts"
to: "route53_audit_log"
via: "INSERT ... status='pending' / UPDATE ... status='committed'|'failed'"
pattern: "route53_audit_log"
- from: "lib/services/route53-write-persistence.ts"
to: "route53_record_history"
via: "INSERT with source='pulse_crud'"
pattern: "pulse_crud"
---
<objective>
Build the two library modules the CRUD routes in plan 24-05 depend on: a server-side record
validator enforcing D-01's writable-type allowlist, and a write-persistence module
implementing the pending → committed/failed audit lifecycle (the single most important
pattern in this phase, lifted from the IT Glue write-back precedent).
Purpose: SC-3 (every CRUD operation logged with actor, timestamp, before/after — including
failures per D-07) and the enforcement half of D-01. These live in `lib/services/` rather
than in route handlers specifically so they are unit-testable — `vitest.config.ts` only
includes `lib/**/*.test.ts`, so validation logic embedded in `app/api/**` route files cannot
be covered by an automated test.
Output: `lib/services/route53-record-validation.ts` and
`lib/services/route53-write-persistence.ts`, both with test files.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-01-SUMMARY.md
<interfaces>
<!-- From plan 24-01. Use directly. -->
lib/types/route53.ts:
Route53WritableType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'
Route53AuditStatus = 'pending' | 'committed' | 'failed'
Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'
Route53RecordValue = { value: string }
Postgres (migration 102):
route53_audit_log(id UUID PK DEFAULT gen_random_uuid(),
operation CHECK IN ('create','update','delete','sync'),
zone_id, record_key, record_name, record_type,
before_value JSONB, after_value JSONB,
performed_by_user_id TEXT FK->"user"(id) ON DELETE SET NULL,
performed_by_email, performed_at, completed_at,
status CHECK IN ('pending','committed','failed'),
aws_change_id, aws_change_status, aws_response JSONB, error_message)
route53_record_history(id UUID PK, zone_id, record_key, record_name, record_type,
change_action CHECK IN ('create','update','delete'),
before_value JSONB, after_value JSONB,
source CHECK IN ('pulse_crud','sync_detected_drift'),
changed_by_user_id, changed_by_email, audit_log_id, changed_at)
route53_records(record_key PK, zone_id, name, type, set_identifier, ttl,
resource_records JSONB, alias_target JSONB, raw_payload JSONB,
created_at, updated_at, synced_at, is_deleted, deleted_at)
lib/services/postgres-client.ts default export `postgresClient`:
.query<T>(sql, params?) -> { rows: T[] }
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Server-side record validation and AWS error sanitizer</name>
<files>lib/services/route53-record-validation.ts, lib/services/route53-record-validation.test.ts</files>
<read_first>
- lib/types/route53.ts (Route53WritableType — created in plan 24-01)
- app/api/analyzer/itglue/applications/[id]/apply/route.ts lines 86-94 (the credential-field blocklist — the exact "belt over the UI's braces" defensive-check pattern to mirror)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Security Domain: ASVS V5 note, Known Threat Patterns table)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-CONTEXT.md (D-01)
</read_first>
<behavior>
- `validateRecordWrite({ type: 'NS', ... })` returns `{ ok: false, status: 400, reason: <message naming NS as a delegation record> }`
- `validateRecordWrite({ type: 'SOA', ... })` returns `{ ok: false, status: 400 }`
- `validateRecordWrite({ type: 'ns', ... })` (lowercase) is also rejected — the check is case-insensitive
- Each of `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV` with a well-formed payload returns `{ ok: true }`
- An unknown type such as `CAA` or `DS` returns `{ ok: false, status: 400 }` — the allowlist is closed, not a blocklist
- Missing or empty `name` returns `{ ok: false, status: 400 }`
- `ttl` outside 0..2147483647, or non-integer, returns `{ ok: false, status: 400 }`
- An empty `resourceRecords` array returns `{ ok: false, status: 400 }` (Route 53 rejects an empty value set)
- A `resourceRecords` entry with an empty-string value returns `{ ok: false, status: 400 }`
- `sanitizeAwsError` strips anything matching an AWS access key id pattern (`AKIA` followed by 16 alphanumerics), any `arn:aws:` substring through the following whitespace, and truncates the result to 500 characters
- `sanitizeAwsError` on a non-Error input returns a string, never throws
</behavior>
<action>
Create `lib/services/route53-record-validation.ts`.
Export `WRITABLE_RECORD_TYPES` as a frozen array of exactly the six D-01 types:
`['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV']`. `NS` and `SOA` must not appear anywhere in
this array. This is a closed allowlist, deliberately not a blocklist — an unrecognized type
is rejected rather than passed through.
Export `validateRecordWrite(input: { name: unknown; type: unknown; ttl?: unknown; resourceRecords?: unknown }): { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string }`
where `ValidatedRecordWrite` is `{ name: string; type: Route53WritableType; ttl: number; resourceRecords: Route53RecordValue[] }`.
Rules, in order, each returning a distinct `reason` string:
1. `name` must be a non-empty string after trimming; normalize by lowercasing and appending
a trailing dot if absent (Route 53's canonical form).
2. `type` must be a string; uppercase it, then it must be a member of
`WRITABLE_RECORD_TYPES`. When the uppercased type is `NS` or `SOA`, use an explicit
reason naming zone delegation, e.g.
`'Record type NS is not writable from Pulse — NS and SOA are zone-delegation records (D-01)'`,
so an operator understands the refusal rather than seeing a generic type error.
3. `ttl` must be an integer between 0 and 2147483647 inclusive; default to 300 when omitted.
4. `resourceRecords` must be a non-empty array whose entries each have a non-empty string
`value`. Cap the array at 100 entries.
Do not introduce Zod — CLAUDE.md says route handlers do not use it and this module is
plain validation. Do not construct any AWS command here; this module has no AWS or DB
dependency.
Export `sanitizeAwsError(err: unknown): string` (T-24-03). Take
`err instanceof Error ? err.message : String(err)`, then redact with regex replacements:
`/AKIA[0-9A-Z]{16}/g``'[redacted-key-id]'`, `/arn:aws:[^\s"']+/g``'[redacted-arn]'`,
and `/\b[0-9]{12}\b/g``'[redacted-account-id]'` (12-digit AWS account ids). Then truncate
to 500 characters with a trailing ellipsis. This is the only string that may be written to
`route53_audit_log.error_message` or returned in an API response body.
Create `lib/services/route53-record-validation.test.ts` covering every `<behavior>` case.
Import vitest primitives explicitly (`globals: false`).
</action>
<verify>
<automated>npx vitest run lib/services/route53-record-validation.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `npx vitest run lib/services/route53-record-validation.test.ts` passes with at least 11 assertions covering every `<behavior>` bullet
- `grep -c "'NS'\|'SOA'" lib/services/route53-record-validation.ts` shows NS/SOA appearing only inside the rejection branch and its reason message, never inside `WRITABLE_RECORD_TYPES`
- The test file asserts rejection for both `'NS'` and `'ns'` (case-insensitivity) and for an unlisted type such as `'CAA'`
- `sanitizeAwsError` test asserts an input containing `AKIAIOSFODNN7EXAMPLE` and `arn:aws:route53:::hostedzone/Z123` produces a string containing neither substring
- `grep -c "@aws-sdk\|postgres-client" lib/services/route53-record-validation.ts` returns 0 — no AWS or DB dependency
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>D-01 allowlist is enforced by tested library code with a closed allowlist; AWS errors have a tested sanitizer.</done>
</task>
<task type="auto">
<name>Task 2: Audit lifecycle and pulse_crud history persistence</name>
<files>lib/services/route53-write-persistence.ts, lib/services/route53-write-persistence.test.ts</files>
<read_first>
- lib/services/analyzer/asset-audit/persistence.ts lines 321-379 (createPendingWrite / markWriteCommitted / markWriteFailed — the exact three-function shape to mirror)
- migrations/075_itglue_audit.sql lines 60-88 (itglue_writes precedent)
- migrations/102_route53_tables.sql (route53_audit_log and route53_record_history columns)
- lib/services/route53-record-validation.ts (sanitizeAwsError — created in Task 1)
- lib/services/postgres-client.ts (query signature and parameter binding style)
- lib/services/route53-record-key.ts (buildRecordKey — created in plan 24-02 Task 1; if plan 24-02 has not landed, import path is still `@/lib/services/route53-record-key`)
</read_first>
<action>
Create `lib/services/route53-write-persistence.ts` following
`lib/services/analyzer/asset-audit/persistence.ts`'s three-function shape exactly.
`createPendingAuditLog(input: { operation: 'create'|'update'|'delete'; zoneId: string; recordKey: string; recordName: string; recordType: string; beforeValue: unknown; afterValue: unknown; performedByUserId: string | null; performedByEmail: string | null }): Promise<{ id: string }>`
`INSERT INTO route53_audit_log (operation, zone_id, record_key, record_name, record_type, before_value, after_value, performed_by_user_id, performed_by_email, status) VALUES ($1,...,$6::jsonb,$7::jsonb,...,'pending') RETURNING id::text AS id`.
This must be callable and complete BEFORE any `ChangeResourceRecordSetsCommand` is
constructed — that discipline is the whole point of the pattern (24-RESEARCH.md Pattern 3:
"never write to the external system without an audit row already in flight").
`markAuditCommitted(id: string, awsChangeId: string | null, awsChangeStatus: string | null, awsResponse: unknown): Promise<void>`
`UPDATE route53_audit_log SET status = 'committed', completed_at = NOW(), aws_change_id = $2, aws_change_status = $3, aws_response = $4::jsonb WHERE id = $1`.
`markAuditFailed(id: string, err: unknown): Promise<void>`
`UPDATE route53_audit_log SET status = 'failed', completed_at = NOW(), error_message = $2 WHERE id = $1`, passing `sanitizeAwsError(err)` as `$2` (D-07 + T-24-03). Never pass a raw
error object or `JSON.stringify(err)`.
`insertPulseCrudHistory(input: { zoneId: string; recordKey: string; recordName: string; recordType: string; changeAction: 'create'|'update'|'delete'; beforeValue: unknown; afterValue: unknown; changedByUserId: string | null; changedByEmail: string | null; auditLogId: string }): Promise<void>`
`INSERT INTO route53_record_history (...) VALUES (..., 'pulse_crud', ...)`. Callers must
invoke this ONLY after `markAuditCommitted` — a failed AWS call changed nothing on AWS's
side, so it gets an audit row but no history row (24-RESEARCH.md Pattern 3, explicit).
Document that rule in a comment above the function.
`upsertMirrorRecord(input: { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, rawPayload }): Promise<void>`
— best-effort refresh of `route53_records` after a committed write so the admin UI reflects
the change before the next scheduled sync. `INSERT ... ON CONFLICT (record_key) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`.
`softDeleteMirrorRecord(recordKey: string): Promise<void>`
`UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE record_key = $1`. Never hard-delete: `route53_record_history` references `record_key`
and the ledger is unbounded by design (D-08).
`loadMirrorRecord(recordKey: string): Promise<MirrorRecordRow | null>`
`SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE record_key = $1 AND is_deleted = false`, returning a camelCase
object (manual snake_case→camelCase transform per CLAUDE.md, no ORM). This supplies the
`before_value` and, critically, the exact TTL and value set that a Route 53 `DELETE` action
requires to match (24-RESEARCH.md Pitfall 3 — a DELETE with a mismatched TTL or value set
fails or targets the wrong thing).
Wrap the mirror-refresh helpers so a failure there is logged (`[ROUTE53-WRITE]` prefix,
`sanitizeAwsError`) but does not throw — the AWS write already succeeded and the next
incremental sync reconciles the mirror regardless. The audit/history writes must NOT be
best-effort; let them throw.
Create `lib/services/route53-write-persistence.test.ts` verifying the SQL contract with a
mocked `postgresClient`: use `vi.mock('@/lib/services/postgres-client', ...)` (follow
whichever mocking style `lib/services/pax8-sync-service.test.ts` already uses in this repo)
and assert (a) `createPendingAuditLog` issues an INSERT whose SQL contains `'pending'`,
(b) `markAuditFailed` passes a sanitized string (an input containing `AKIAIOSFODNN7EXAMPLE`
does not appear in the bound parameters), (c) `insertPulseCrudHistory` binds the literal
`'pulse_crud'`, (d) `softDeleteMirrorRecord` issues an UPDATE and never a `DELETE FROM`.
</action>
<verify>
<automated>npx vitest run lib/services/route53-write-persistence.test.ts && npx tsc --noEmit --pretty && npm test</automated>
</verify>
<acceptance_criteria>
- `lib/services/route53-write-persistence.ts` exports `createPendingAuditLog`, `markAuditCommitted`, `markAuditFailed`, `insertPulseCrudHistory`, `upsertMirrorRecord`, `softDeleteMirrorRecord`, `loadMirrorRecord`
- `npx vitest run lib/services/route53-write-persistence.test.ts` passes with the four assertions listed in the action
- `grep -c 'DELETE FROM route53_records' lib/services/route53-write-persistence.ts` returns 0 (soft-delete only, D-08)
- `markAuditFailed` calls `sanitizeAwsError`: `grep -q 'sanitizeAwsError' lib/services/route53-write-persistence.ts`
- `grep -c "'pulse_crud'" lib/services/route53-write-persistence.ts` >= 1
- A comment above `insertPulseCrudHistory` states that it must not be called on a failed AWS write
- `npm test` full suite exits 0
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>Audit lifecycle and history persistence exist with tested SQL contracts; failures are sanitized; no hard deletes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP request body → validation module | Untrusted operator-supplied record payload crosses into logic that will mutate live DNS |
| AWS SDK error → Postgres / HTTP response | Error text may carry account ids, ARNs, or key ids |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-01 | Tampering | record `type` field in a write request | mitigate | `validateRecordWrite` enforces a closed allowlist of the six D-01 types in `lib/services/route53-record-validation.ts`, rejecting `NS`/`SOA` (case-insensitively) with status 400. Enforced in library code the routes call before constructing any AWS command — never relying on the UI hiding the option. Unit-tested. |
| T-24-03 | Information Disclosure | `route53_audit_log.error_message` and API error responses | mitigate | `sanitizeAwsError` redacts AKIA-prefixed key ids, `arn:aws:*` strings, and 12-digit account ids, then truncates to 500 chars. It is the only permitted source of `error_message` values, asserted by a unit test. |
| T-24-04 | Repudiation | Pulse-initiated record writes | mitigate | `createPendingAuditLog` runs before any AWS call, capturing actor (`performed_by_user_id`/`performed_by_email`), timestamp, and before/after. A crashed process leaves a `pending` row, which is itself evidence an attempt occurred (SC-3). |
| T-24-07 | Tampering | audit/history rows treated as best-effort | mitigate | Audit and history writes intentionally throw on failure; only mirror-refresh helpers are best-effort. A DB failure must fail the request rather than silently produce an unlogged DNS mutation. |
| T-24-12 | Denial of Service | oversized `resourceRecords` array in a write request | mitigate | Validation caps `resourceRecords` at 100 entries and rejects empty-string values before any AWS call. |
| T-24-05 | Tampering / Spoofing | semantic content of record values (dangling CNAME, SPF/DKIM TXT) | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with no pre-write approval gate; `route53_audit_log` before/after + actor is the compensating post-hoc control. Shape validation here explicitly does NOT attempt semantic threat detection. |
</threat_model>
<verification>
- `npx vitest run lib/services/route53-record-validation.test.ts lib/services/route53-write-persistence.test.ts` green
- `npm test` full suite green
- `npx tsc --noEmit --pretty` exits 0
- `grep -rn "NS\b" lib/services/route53-record-validation.ts` confirms NS appears only in the rejection path
</verification>
<success_criteria>
- D-01 allowlist enforced by tested library code, closed (unknown types rejected)
- AWS errors sanitized before storage or client return
- pending → committed/failed lifecycle implemented with the audit row created before any AWS call
- `pulse_crud` history rows written only after a committed write
- Mirror updates are soft-delete only
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-03-SUMMARY.md` when done.
</output>

View file

@ -0,0 +1,300 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 04
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
- lib/services/route53-dns-delegation.ts
- lib/services/route53-dns-delegation.test.ts
- lib/services/integration-health.ts
autonomous: true
requirements: [SC-6]
must_haves:
truths:
- "SC-6: Route 53 appears in the integration health list under key 'route53', with the same not_configured / ok / auth_failed / unreachable status vocabulary every other integration uses"
- "D-12: The health check compares each hosted zone's Route-53-authoritative NS records against a LIVE public DNS lookup for that domain, and a mismatch degrades the reported health — there is no manually-maintained 'expected NS' field anywhere"
- "D-12: The live lookup uses a dedicated dns.Resolver() instance with setServers(['1.1.1.1','8.8.8.8']); the process-global dns.setServers() is never called, so internal service hostname resolution is unaffected"
- "D-10: The 'route53' health result flows through the existing applyDisableOverlay(), so disabling Route 53 in /admin/integrations suppresses the health display only — no sync or CRUD path consults integration_settings"
artifacts:
- path: "lib/services/route53-dns-delegation.ts"
provides: "NS normalization + mismatch detection + live resolver lookup, split so the pure half is unit-testable"
exports: ["normalizeNsList", "compareNsDelegation", "resolveLiveNs", "checkAllZoneDelegations"]
- path: "lib/services/route53-dns-delegation.test.ts"
provides: "NS normalization and mismatch-detection coverage"
- path: "lib/services/integration-health.ts"
provides: "checkRoute53() registered in checkIntegrationHealth()'s Promise.all"
contains: "checkRoute53"
key_links:
- from: "lib/services/integration-health.ts"
to: "lib/services/route53-factory.ts"
via: "isRoute53Configured() gate + ListHostedZonesCommand auth probe"
pattern: "isRoute53Configured"
- from: "lib/services/integration-health.ts"
to: "lib/services/route53-dns-delegation.ts"
via: "checkAllZoneDelegations() call inside checkRoute53()"
pattern: "checkAllZoneDelegations"
- from: "lib/services/route53-dns-delegation.ts"
to: "node:dns"
via: "dedicated Resolver instance with setServers"
pattern: "new Resolver\\("
---
<objective>
Add Route 53 to the integration health system with the D-12 DNS-specific delegation check:
beyond the standard auth probe and last-sync age, compare each hosted zone's
Route-53-authoritative name servers against a live public DNS lookup and flag mismatches as
degraded health.
Purpose: SC-6 (integration appears in the existing health/admin surface alongside the
others) plus D-12's DNS-specific extension.
Output: `lib/services/route53-dns-delegation.ts` (+ tests) and a `checkRoute53()` function
registered in `lib/services/integration-health.ts`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-01-SUMMARY.md
<interfaces>
<!-- Existing contract from lib/services/integration-health.ts — extend, do not redesign. -->
export type HealthStatus = /* union defined at line 18 — includes 'ok', 'not_configured',
'auth_failed', 'unreachable', 'unknown', 'disabled'; read the live union before writing */
export interface IntegrationHealth {
key: string;
name: string;
category: 'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity'
| 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm';
status: HealthStatus;
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: TokenExpiry | null;
checkedAt: string;
}
export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]>
// line ~325: results = await Promise.all([ checkAutotask(), checkDattoRmm(), checkItglue(), checkS1(), ...checkConfigOnly wrappers ])
// line ~354: const overlaid = await applyDisableOverlay(results); <- D-10 disable overlay, already generic by key
<!-- From plan 24-01 -->
lib/services/route53-factory.ts:
isRoute53Configured(): boolean
getRoute53Client(): Route53Client
Postgres (migration 102):
route53_zones(id, name, authoritative_name_servers JSONB, is_deleted, synced_at, ...)
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: NS normalization and delegation-comparison module</name>
<files>lib/services/route53-dns-delegation.ts, lib/services/route53-dns-delegation.test.ts</files>
<read_first>
- lib/services/pipeline-steps/ping-flap-suppress.ts line 6 (the existing `import { promises as dns } from 'dns'` precedent in this codebase)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Code Examples: checkNsDelegation; Pitfall 5: never call global dns.setServers)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (the recorded DNS-egress result — EGRESS-OK or EGRESS-BLOCKED — decides the resolver strategy in Task 2)
- migrations/102_route53_tables.sql (route53_zones.authoritative_name_servers)
</read_first>
<behavior>
- `normalizeNsList(['NS-123.AWSDNS-45.com.', 'ns-999.awsdns-01.org'])` returns `['ns-123.awsdns-45.com', 'ns-999.awsdns-01.org']` — lowercased, trailing dot stripped
- `normalizeNsList` returns `[]` for `null`, `undefined`, and a non-array input
- `normalizeNsList` de-duplicates and sorts, so ordering differences never register as a mismatch
- `compareNsDelegation(authoritative, live)` with identical sets returns `{ mismatch: false, missingFromLive: [], extraInLive: [] }`
- `compareNsDelegation` returns `mismatch: true` with a populated `missingFromLive` when an authoritative NS is absent from the live answer
- `compareNsDelegation` returns `mismatch: true` with a populated `extraInLive` when the live answer contains an NS Route 53 does not consider authoritative
- `compareNsDelegation(authoritative, [])` returns `mismatch: true` (a domain with no live NS answer is a delegation problem, not a pass)
- `compareNsDelegation([], live)` returns `mismatch: false` — a zone with no recorded authoritative NS cannot be judged, so it must not produce a false alarm
- Comparison is case-insensitive and trailing-dot-insensitive on both sides
</behavior>
<action>
Create `lib/services/route53-dns-delegation.ts` split into a pure half and an I/O half so the
comparison logic is unit-testable without network access.
Pure exports:
- `normalizeNsList(input: unknown): string[]` — returns `[]` for non-arrays; otherwise maps
each entry through `String(x).trim().toLowerCase().replace(/\.$/, '')`, drops empty
strings, de-duplicates via a `Set`, and sorts.
- `compareNsDelegation(authoritative: unknown, live: unknown): { mismatch: boolean; authoritative: string[]; live: string[]; missingFromLive: string[]; extraInLive: string[] }`
— normalizes both sides, then computes set differences. Returns `mismatch: false` when the
normalized authoritative list is empty (unjudgeable, not a failure). Returns
`mismatch: true` when the normalized live list is empty but the authoritative list is not.
Otherwise `mismatch` is `missingFromLive.length > 0 || extraInLive.length > 0`.
I/O exports:
- `resolveLiveNs(domain: string, timeoutMs = 5000): Promise<{ ok: true; nameServers: string[] } | { ok: false; error: string }>`
— construct `new Resolver()` from `node:dns` (import `Resolver` from `'dns'`, matching the
existing codebase precedent in `ping-flap-suppress.ts`), call
`resolver.setServers(['1.1.1.1', '8.8.8.8'])` on that instance, then `resolveNs`
(promisified via `util.promisify(resolver.resolveNs.bind(resolver))` or the
`resolver.resolveNs` callback wrapped in a `Promise`). Race it against a timeout that
calls `resolver.cancel()` and resolves `{ ok: false, error: 'DNS lookup timed out after Nms' }`.
Strip the trailing dot from `domain` before lookup.
CRITICAL (24-RESEARCH.md Pitfall 5): never call the module-level `dns.setServers()` — that
would repoint DNS resolution for the entire Node process, including Postgres and Redis
hostname resolution. Add an inline comment stating this.
- `checkAllZoneDelegations(zones: Array<{ id: string; name: string; authoritativeNameServers: unknown }>, opts?: { concurrency?: number }): Promise<Array<{ zoneId: string; zoneName: string; mismatch: boolean; error?: string; missingFromLive: string[]; extraInLive: string[] }>>`
— resolve each zone's live NS and compare. Run at most `concurrency` (default 5) lookups in
parallel so a large zone list does not open hundreds of concurrent UDP sockets. A lookup
error yields `{ mismatch: false, error: <message> }` — an unreachable resolver is an
infrastructure problem, not evidence of delegation drift, and must not be reported as a
mismatch. Skip zones whose `authoritativeNameServers` normalizes to an empty list.
Create `lib/services/route53-dns-delegation.test.ts` covering every pure-half `<behavior>`
case. Do not test `resolveLiveNs` against a live resolver — network calls in unit tests are
flaky; that path is covered by the manual verification in 24-VALIDATION.md.
Import vitest primitives explicitly (`globals: false`).
</action>
<verify>
<automated>npx vitest run lib/services/route53-dns-delegation.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- `npx vitest run lib/services/route53-dns-delegation.test.ts` passes with at least 9 assertions covering every `<behavior>` bullet
- `grep -c 'dns.setServers\|setServers(\[.*\])' lib/services/route53-dns-delegation.ts` shows `setServers` called only on a `Resolver` instance variable, never on the imported `dns` module namespace
- `grep -q 'new Resolver(' lib/services/route53-dns-delegation.ts`
- `compareNsDelegation([], ['ns1.example.com'])` returns `mismatch: false` — asserted in the test file (no false alarm on unjudgeable zones)
- `compareNsDelegation(['ns1.example.com'], [])` returns `mismatch: true` — asserted in the test file
- The test file contains no network call: `grep -c 'resolveLiveNs' lib/services/route53-dns-delegation.test.ts` returns 0
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>Pure NS comparison logic fully unit-tested; live resolver isolated to a dedicated instance; global DNS untouched.</done>
</task>
<task type="auto">
<name>Task 2: Register checkRoute53() in the integration health aggregator</name>
<files>lib/services/integration-health.ts</files>
<read_first>
- lib/services/integration-health.ts (read in full — HealthStatus union at line ~18, IntegrationHealth interface at line ~33, checkDattoRmm() at lines 145-191 for the custom-body live-check pattern, checkItglue() at lines 193-209, checkConfigOnly() at lines 238-252, applyDisableOverlay() at lines ~310-319, checkIntegrationHealth() Promise.all at lines 325-352)
- lib/services/route53-dns-delegation.ts (created in Task 1)
- lib/services/route53-factory.ts (created in plan 24-01)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md (DNS-egress result)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (integration-health section)
</read_first>
<action>
Modify `lib/services/integration-health.ts` only — do not create a parallel health module.
Extend the `IntegrationHealth` interface with two optional fields (optional so no existing
check function needs changing):
`nsDelegationMismatches?: string[] | null` (zone names with a delegation mismatch) and
`nsDelegationErrors?: string[] | null` (zone names whose live lookup failed). Do NOT overload
the existing `error` field for this — 24-PATTERNS.md calls this out explicitly.
Add `async function checkRoute53(): Promise<IntegrationHealth>` placed next to
`checkDattoRmm()`, using `key: 'route53'`, `name: 'AWS Route 53'`, `category: 'network'`
(an existing member of the category union — do not add a new category value). Behavior:
1. Config gate, mirroring `checkDattoRmm()`'s early return: if `isRoute53Configured()` is
false, return `status: 'not_configured', configured: false` with `checkedAt` set. Do not
construct the client.
2. Auth probe: `getRoute53Client().send(new ListHostedZonesCommand({ MaxItems: '1' }))`
wrapped in try/catch, timing it for `latencyMs`. On an AWS SDK error whose `name` or
`$metadata.httpStatusCode` indicates a credential/authorization problem
(`InvalidClientTokenId`, `SignatureDoesNotMatch`, `AccessDenied`, `UnrecognizedClientException`,
or HTTP 401/403), return `status: 'auth_failed'`. On any other error return
`status: 'unreachable'`. In both branches set `error` to the sanitized message from
`sanitizeAwsError` in `lib/services/route53-record-validation.ts` (T-24-03) — never the
raw AWS error object, which carries `$metadata` including request ids.
3. D-12 delegation check: query
`SELECT id, name, authoritative_name_servers FROM route53_zones WHERE is_deleted = false`
and pass the rows (transformed to the `{ id, name, authoritativeNameServers }` camelCase
shape) to `checkAllZoneDelegations()`. Collect zone names where `mismatch === true` into
`nsDelegationMismatches` and zone names with an `error` into `nsDelegationErrors`.
If `nsDelegationMismatches` is non-empty, downgrade the returned `status` from `'ok'` to
the existing degraded-status member of the `HealthStatus` union — read the union at
line ~18 and use the member that already represents "reachable but not healthy"; if the
union has no such member, add `'degraded'` to it and confirm every consumer that
switches on `HealthStatus` (grep for `status ===` across `app/` and `components/`)
renders an unknown value without crashing.
Set `error` to a summary such as
`'NS delegation mismatch for N zone(s): example.com, other.com'` when mismatches exist.
4. Bound the total cost: if the zone list exceeds 50 zones, check only the first 50 by name
order and note the truncation in `error`. The health check runs behind a 5-minute cache
and must not become the slowest call in the aggregate.
5. Wrap the whole delegation step in try/catch — a Postgres failure or a blocked resolver
must degrade to `nsDelegationErrors` and leave the auth-probe status intact, never throw
out of `checkIntegrationHealth()`'s `Promise.all`.
If plan 24-01's SUMMARY recorded `EGRESS-BLOCKED` for the DNS smoke test, implement
`resolveLiveNs`'s fallback path instead: a DoH GET to
`https://cloudflare-dns.com/dns-query?name=<domain>&type=NS` with header
`Accept: application/dns-json`, parsing `Answer[].data` — same normalized output shape, no
new npm dependency (uses `fetch`). Note which path was taken in the SUMMARY.
Register the check in `checkIntegrationHealth()`'s `Promise.all` array (line ~325) as a bare
`checkRoute53(),` call alongside `checkAutotask()` / `checkDattoRmm()` — not wrapped in
`Promise.resolve()`, which is only used for the synchronous `checkConfigOnly()` helpers.
Do NOT add any Route 53 branch to `applyDisableOverlay()` — it already keys off
`item.key`, so the `'route53'` result is covered automatically (D-10, display-only).
</action>
<verify>
<automated>npx tsc --noEmit --pretty && grep -q "checkRoute53()," lib/services/integration-health.ts && grep -q "key: 'route53'" lib/services/integration-health.ts && npm test</automated>
</verify>
<acceptance_criteria>
- `lib/services/integration-health.ts` contains an `async function checkRoute53()` returning `key: 'route53'`, `name: 'AWS Route 53'`, `category: 'network'`
- `checkRoute53(),` appears inside `checkIntegrationHealth()`'s `Promise.all([...])` array, unwrapped
- `IntegrationHealth` gained `nsDelegationMismatches?` and `nsDelegationErrors?` as optional fields; `npx tsc --noEmit --pretty` exits 0 with no changes required in any other check function
- `grep -c 'integration_settings' lib/services/integration-health.ts` is unchanged from before this task (the disable overlay already existed; no new Route-53-specific disable logic added — D-10)
- The auth-probe catch branch passes its error through `sanitizeAwsError`: `grep -q 'sanitizeAwsError' lib/services/integration-health.ts`
- `curl -s localhost:3100/api/admin/integration-health` (or whichever route already serves `checkIntegrationHealth`, found by `grep -rl checkIntegrationHealth app/api`) returns a JSON array containing an object with `"key":"route53"`
- `npm test` full suite exits 0
</acceptance_criteria>
<done>Route 53 appears in the health aggregate with an auth probe plus D-12 delegation check; disable overlay works via the existing generic path; no other integration's check regressed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Pulse container → public DNS resolvers (1.1.1.1 / 8.8.8.8, UDP/53 or DoH/443) | Outbound network call to a third party whose answer influences a health verdict |
| AWS Route 53 API → health check | Auth probe error text may carry request metadata |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-13 | Denial of Service | process-global DNS resolver configuration | mitigate | `resolveLiveNs` calls `setServers` on a dedicated `new Resolver()` instance only. The process-global `dns.setServers()` is never invoked, so Postgres/Redis/AWS hostname resolution inside the container is unaffected. Asserted in Task 1 acceptance criteria. |
| T-24-14 | Denial of Service | unbounded parallel NS lookups across a large zone list | mitigate | `checkAllZoneDelegations` runs at most 5 concurrent lookups, each with a 5s timeout and `resolver.cancel()`, and `checkRoute53` caps the checked zone list at 50. The whole check sits behind the existing 5-minute health cache. |
| T-24-03 | Information Disclosure | auth-probe error surfaced in the health API response (readable by any authenticated user) | mitigate | Auth-probe errors pass through `sanitizeAwsError` before being placed in `IntegrationHealth.error`, redacting key ids, ARNs, and account ids. |
| T-24-15 | Spoofing | a third-party public resolver returning a forged NS answer | accept | The check is advisory health signalling, not an enforcement gate — a false mismatch degrades a status badge and triggers human investigation; it cannot cause a DNS mutation. Two independent resolvers (1.1.1.1 and 8.8.8.8) are configured, and lookup failures are reported as `nsDelegationErrors` rather than mismatches so an unreachable/hostile resolver cannot manufacture a false-positive drift alarm. |
| T-24-16 | Denial of Service | an exception in the delegation step aborting `Promise.all` and blanking every integration's health | mitigate | The entire delegation step is wrapped in try/catch inside `checkRoute53`; failures degrade to `nsDelegationErrors` while preserving the auth-probe status. |
</threat_model>
<verification>
- `npx vitest run lib/services/route53-dns-delegation.test.ts` green
- `npm test` full suite green
- `npx tsc --noEmit --pretty` exits 0
- The health endpoint returns a `route53` entry (curl assertion in Task 2 acceptance criteria)
- Toggling `route53` off at `/admin/integrations` flips its status to `disabled` within the 5-minute cache while a manual `POST /api/route53/sync` still works (D-10) — confirmed at the plan 24-07 checkpoint
</verification>
<success_criteria>
- `route53` present in the integration health list with the standard status vocabulary
- D-12 live NS comparison implemented against a dedicated resolver instance
- Delegation mismatches degrade the reported status and are enumerated in `nsDelegationMismatches`
- Lookup failures are reported separately and never counted as mismatches
- No process-global DNS mutation; no Route-53-specific disable gating
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-04-SUMMARY.md` when done.
Record whether the Node `dns` path or the DoH fallback was used, and the exact
`HealthStatus` union member chosen for the degraded state.
</output>

View file

@ -0,0 +1,407 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 05
type: execute
wave: 3
depends_on: ["24-02", "24-03"]
files_modified:
- 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
autonomous: true
requirements: [SC-2, SC-3, SC-4]
must_haves:
truths:
- "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"
artifacts:
- path: "lib/services/route53-change-submit.ts"
provides: "ChangeResourceRecordSets construction + bounded GetChange poll, testable without a route"
exports: ["buildChangeBatch", "submitRecordChange", "pollChangeStatus"]
- path: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
provides: "PATCH (update) and DELETE handlers with requireAdmin gating"
exports: ["PATCH", "DELETE"]
- path: "app/api/route53/zones/[zoneId]/records/route.ts"
provides: "GET (list records in zone) and POST (create record)"
exports: ["GET", "POST"]
- path: "app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts"
provides: "GET record change history (SC-4 queryable ledger)"
exports: ["GET"]
- path: "app/api/route53/sync/route.ts"
provides: "POST manual sync trigger + GET sync status"
exports: ["GET", "POST"]
key_links:
- from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
to: "lib/services/route53-record-validation.ts"
via: "validateRecordWrite() call before any AWS command construction"
pattern: "validateRecordWrite"
- from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
to: "lib/services/route53-write-persistence.ts"
via: "createPendingAuditLog before the AWS call, markAuditCommitted/markAuditFailed after"
pattern: "createPendingAuditLog"
- from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
to: "lib/auth-utils.ts"
via: "requireAdmin() gate"
pattern: "requireAdmin"
- from: "app/api/route53/sync/route.ts"
to: "lib/services/route53-sync-service.ts"
via: "getRoute53SyncService().fullSync() fire-and-forget"
pattern: "getRoute53SyncService"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From plans 24-01, 24-02, 24-03. Use directly — no codebase exploration needed. -->
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<Route53SyncResult>
Route53SyncService#incrementalSync(triggeredBy?): Promise<Route53SyncResult>
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<void>
markAuditFailed(id, err): Promise<void>
insertPulseCrudHistory(input): Promise<void>
upsertMirrorRecord(input): Promise<void>
softDeleteMirrorRecord(recordKey): Promise<void>
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'), ...)
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Change-batch construction and bounded propagation poll</name>
<files>lib/services/route53-change-submit.ts, lib/services/route53-change-submit.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- `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
</behavior>
<action>
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`).
</action>
<verify>
<automated>npx vitest run lib/services/route53-change-submit.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<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>
<done>Change construction, retry classification, and bounded polling are tested library code with no 30-minute waiter.</done>
</task>
<task type="auto">
<name>Task 2: Read routes — zones, records, history, and sync status</name>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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.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.
</action>
<verify>
<automated>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</automated>
</verify>
<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>
<done>Four read surfaces exist, all auth-gated, all parameterized, none gated on the disable toggle.</done>
</task>
<task type="auto">
<name>Task 3: CRUD write routes — create, update, delete with the audit lifecycle</name>
<files>app/api/route53/zones/[zoneId]/records/route.ts, app/api/route53/zones/[zoneId]/records/[recordId]/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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/<zone>/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
</verification>
<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>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-05-SUMMARY.md` when done.
</output>

View file

@ -0,0 +1,254 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 06
type: execute
wave: 3
depends_on: ["24-02"]
files_modified:
- lib/services/sync-scheduler.ts
- app/admin/sync/page.tsx
- public/logos/route53.svg
autonomous: true
requirements: [SC-1, SC-6]
must_haves:
truths:
- "SC-1/D-11: Two cron schedules exist — route53-incremental (every 15 minutes) and route53-full (daily) — both seeded into sync_schedules and editable from /admin, matching how every other integration's schedules are managed"
- "SC-6: A Route 53 tile appears in the /admin/sync integration list with the same shape as the Veeam / Datto RMM / PAX8 tiles, linking to /admin/sync/route53"
- "D-10: The scheduler dispatch branch for Route 53 checks isRoute53Configured() only — it does NOT consult integration_settings.disabled, so disabling Route 53 in /admin/integrations suppresses health display without stopping sync (PAX8 remains the sole blocking exception)"
- "New schedules are seeded is_enabled: false, matching every other newly-introduced integration in this file, so nothing starts hitting AWS before an operator enables it"
artifacts:
- path: "lib/services/sync-scheduler.ts"
provides: "route53-incremental and route53-full in the sync_type union, defaultSchedules, and the dispatch chain"
contains: "route53-incremental"
- path: "app/admin/sync/page.tsx"
provides: "route53 entry in the INTEGRATIONS tile array"
contains: "id: 'route53'"
- path: "public/logos/route53.svg"
provides: "Tile logo asset"
key_links:
- from: "lib/services/sync-scheduler.ts"
to: "lib/services/route53-sync-service.ts"
via: "dynamic import of getRoute53SyncService inside the dispatch branch"
pattern: "getRoute53SyncService"
- from: "app/admin/sync/page.tsx"
to: "/admin/sync/route53"
via: "tile href"
pattern: "/admin/sync/route53"
---
<objective>
Wire Route 53 into the two existing operator surfaces it must appear in: the node-cron sync
scheduler (D-11's incremental + daily-full cadence) and the `/admin/sync` integration tile
list.
Purpose: SC-1 (sync runs on a schedule) and SC-6 (integration appears in the existing sync
admin UI/scheduler alongside the others).
Output: modified `lib/services/sync-scheduler.ts` and `app/admin/sync/page.tsx`, plus a
`public/logos/route53.svg` tile asset.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-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-02-SUMMARY.md
<interfaces>
<!-- Existing contracts in the files being modified. Read the live files; these are the anchors. -->
lib/services/sync-scheduler.ts:
line 25: sync_type: 'incremental' | 'full' | 'veeam-incremental' | ... | 'phishing-sweep';
line 147: sync_schedules table DDL — sync_type VARCHAR(30) NOT NULL (no CHECK constraint;
'route53-incremental' is 19 chars and fits)
line 180: const defaultSchedules = [ ... ] // entries: { id, name, description, cron_expression, sync_type, is_enabled }
line 312: for (const schedule of defaultSchedules) { INSERT INTO sync_schedules ... ON CONFLICT DO NOTHING }
line 413+: dispatch chain — if (config.sync_type === 'veeam-incremental') { ... } else if (...)
line 478-493: pax8-daily branch — the ONE branch that also checks integration_settings.disabled.
Route 53 must NOT copy that gate (D-10).
line 494-504: mimecast-sync branch — config-check-only pattern; this is the shape to copy.
app/admin/sync/page.tsx:
lines 9-16: interface IntegrationCard { id, category, product, description, href, logo, color }
lines 20-29: const INTEGRATIONS: IntegrationCard[] = [ ... 'pax8' entry is last ]
lines 32-39: COLOR_MAP — available keys: red, green, blue, orange, purple, gray (no others)
line 266: const colors = COLOR_MAP[intg.color]; // an unmapped color yields undefined
<!-- From plan 24-02 -->
lib/services/route53-sync-service.ts:
getRoute53SyncService(): Route53SyncService
#fullSync(triggeredBy?): Promise<Route53SyncResult>
#incrementalSync(triggeredBy?): Promise<Route53SyncResult>
<!-- From plan 24-01 -->
lib/services/route53-factory.ts:
isRoute53Configured(): boolean
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add route53 sync types, schedules, and dispatch branches to the scheduler</name>
<files>lib/services/sync-scheduler.ts</files>
<read_first>
- lib/services/sync-scheduler.ts (read the full file — the sync_type union at line 25, the sync_schedules DDL at line 147, defaultSchedules at lines 180-311, the seed loop at line 312, and the whole dispatch chain from line 413 onward)
- lib/services/route53-sync-service.ts (getRoute53SyncService — plan 24-02)
- lib/services/route53-factory.ts (isRoute53Configured — plan 24-01)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (sync-scheduler section — the exact branch shape, and the explicit instruction NOT to copy PAX8's disable gate)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-CONTEXT.md (D-10, D-11)
</read_first>
<action>
Modify `lib/services/sync-scheduler.ts` in three places.
1. Line 25 `sync_type` union: append `| 'route53-incremental' | 'route53-full'` to the
existing pipe-delimited string union. Do not reformat the rest of the line.
2. `defaultSchedules` array (starts line 180): append two entries matching the exact object
shape of the surrounding `veeam-incremental` / `veeam-full` entries at lines 198-221:
- id `route53-incremental`, name `Route 53 Incremental Sync`, description
`Syncs AWS Route 53 hosted zones and records every 15 minutes`, cron_expression
`*/15 * * * *`, sync_type `route53-incremental`, is_enabled `false`.
- id `route53-full`, name `Route 53 Full Sync`, description
`Full AWS Route 53 zone and record reconciliation daily at 4:00 AM`, cron_expression
`0 4 * * *`, sync_type `route53-full`, is_enabled `false`.
`is_enabled: false` matches every newly-introduced integration in this file — an operator
enables them from `/admin` after confirming credentials. The `0 4 * * *` slot avoids the
known collisions at 2:00 AM (`veeam-full`, `qbo-sync-2am`, `mimecast-sync`) and 3:00 AM
(`weekly-full`'s `0 3 * * 0`). Enumerate every `cron_expression` in the live array before
committing and pick the next free hour if 4:00 AM is now occupied.
The seed loop at line 312 uses `ON CONFLICT DO NOTHING`, so existing deployments pick
these up without overwriting operator-modified rows.
3. Dispatch chain (line 413 onward): add two `else if` branches following the
`mimecast-sync` config-check-only shape at lines 494-504, NOT the `pax8-daily` shape at
lines 478-493:
- `else if (config.sync_type === 'route53-incremental')` — dynamically
`await import('@/lib/services/route53-factory')` for `isRoute53Configured`; if false,
log `[SCHEDULER] Skipping route53-incremental — Route 53 not configured` and return;
otherwise dynamically `await import('@/lib/services/route53-sync-service')` and call
`getRoute53SyncService().incrementalSync('scheduled')`.
- `else if (config.sync_type === 'route53-full')` — same shape, calling
`fullSync('scheduled')`.
Use dynamic `import()` in both branches (as every other branch does) so the sync service
module is not eager-loaded at scheduler-import time. The scheduler self-initializes as a
side effect of its first server-side import, and a top-level import here would pull the
AWS SDK into every server module graph.
CRITICAL (D-10): do NOT add an `integration_settings.disabled` query to either branch. PAX8
is the codebase's sole exception where disabling also blocks sync; CONTEXT.md D-10 states
Route 53 explicitly does not join that list. Add a short comment above the first Route 53
branch recording this, so a future reader does not "fix" the apparent inconsistency with the
PAX8 branch sitting a few lines above.
Confirm `sync_schedules.sync_type` at line 147 is `VARCHAR(30)` with no CHECK constraint
before relying on the new values fitting — `route53-incremental` is 19 characters.
</action>
<verify>
<automated>npx tsc --noEmit --pretty && test $(grep -c "route53-incremental" lib/services/sync-scheduler.ts) -ge 3 && test $(grep -c "route53-full" lib/services/sync-scheduler.ts) -ge 3 && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts | grep -c integration_settings) -eq 0 && echo PASS</automated>
</verify>
<acceptance_criteria>
- `route53-incremental` and `route53-full` each appear at least 3 times in `lib/services/sync-scheduler.ts` (union, defaultSchedules, dispatch)
- Both new `defaultSchedules` entries have `is_enabled: false`
- Neither Route 53 dispatch branch body contains `integration_settings` (D-10) — grep over the 12 lines following each branch head returns 0
- Both dispatch branches use dynamic `await import(...)`; no top-level route53 import exists: `grep -c "^import.*route53" lib/services/sync-scheduler.ts` returns 0
- The `route53-full` cron hour differs from every other daily `cron_expression` hour in `defaultSchedules` — verified by enumerating the array
- A comment above the Route 53 branches records why the PAX8 disable gate is deliberately absent
- `npx tsc --noEmit --pretty` exits 0
</acceptance_criteria>
<done>Scheduler knows both Route 53 sync types, seeds them disabled, dispatches via dynamic import, and never consults the disable toggle.</done>
</task>
<task type="auto">
<name>Task 2: Add the Route 53 tile to /admin/sync</name>
<files>app/admin/sync/page.tsx, public/logos/route53.svg</files>
<read_first>
- app/admin/sync/page.tsx (read the full file — the IntegrationCard interface at lines 9-16, the INTEGRATIONS array at lines 20-29, COLOR_MAP at lines 32-39, and the tile render at line ~266 to confirm how `logo` is consumed)
- public/logos/ directory listing (confirm the existing asset naming convention — every current asset is `.ico`)
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (admin sync page section)
</read_first>
<action>
Create `public/logos/route53.svg` — a small hand-authored SVG with `viewBox="0 0 32 32"`,
`xmlns="http://www.w3.org/2000/svg"`, using the AWS orange `#FF9900`, containing a simple
globe-or-DNS-node glyph built from primitive shapes only. It must contain no `<script>`
element, no external `href`/`xlink:href` reference, and no embedded raster data. Every
existing asset in `public/logos/` is an `.ico`; an SVG is used here because the file must be
authored offline rather than downloaded. Read the tile render near line 266 first: if `logo`
is consumed as a plain image `src`, `.svg` works unchanged; if anything assumes an `.ico`
extension, change the asset format rather than the render code.
Append one entry to the `INTEGRATIONS` array in `app/admin/sync/page.tsx`, after the `pax8`
entry, matching the existing single-line object formatting and column alignment:
`id` `route53`, `category` `DNS`, `product` `AWS Route 53`, `description`
`Hosted zones, DNS records, change history, NS-delegation health`, `href`
`/admin/sync/route53`, `logo` `/logos/route53.svg`, `color` `orange`.
`color` must be a key present in `COLOR_MAP` (lines 32-39: `red`, `green`, `blue`, `orange`,
`purple`, `gray`) — `COLOR_MAP[intg.color]` is dereferenced at line ~266 and an unmapped key
yields `undefined`. `orange` is already used by `datto-rmm`, and reuse is already the norm in
this array (`blue` three times, `green` twice, `purple` twice).
Do not modify `IntegrationCard`, `COLOR_MAP`, or the render logic. This task is one array
append plus one static asset.
</action>
<verify>
<automated>npx tsc --noEmit --pretty && grep -q "id: 'route53'" app/admin/sync/page.tsx && grep -q "/admin/sync/route53" app/admin/sync/page.tsx && test -f public/logos/route53.svg && test $(grep -ci "script\|xlink:href" public/logos/route53.svg) -eq 0 && echo PASS</automated>
</verify>
<acceptance_criteria>
- `public/logos/route53.svg` exists, opens with an `<svg` root carrying a `viewBox` attribute, and contains zero occurrences of `script` or `xlink:href`
- `app/admin/sync/page.tsx` `INTEGRATIONS` array contains an entry with `id: 'route53'` and `href: '/admin/sync/route53'`
- The entry's `color` value is one of `red|green|blue|orange|purple|gray` (a key present in `COLOR_MAP`)
- `git diff app/admin/sync/page.tsx` shows only added lines inside the `INTEGRATIONS` array — no change to `IntegrationCard`, `COLOR_MAP`, or the JSX below
- `npx tsc --noEmit --pretty` exits 0
- `curl -s -o /dev/null -w '%{http_code}' localhost:3100/logos/route53.svg` returns 200 when the dev server is running
</acceptance_criteria>
<done>The Route 53 tile renders on /admin/sync with a valid color key and a working logo asset, linking to the detail page built in plan 24-07.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| node-cron scheduler → AWS Route 53 API | Unattended, recurring outbound calls with production credentials |
| Static asset → browser | SVG served from `public/` renders inline in an authenticated admin page |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-21 | Denial of Service | unattended sync hitting Route 53 API rate limits | mitigate | 15-minute incremental plus one daily full is well inside Route 53's current token-bucket limits (50-burst / 10-per-second default action bucket per 24-RESEARCH.md's verified throttling numbers). Both schedules seed `is_enabled: false` so nothing runs until an operator enables it, and cadence stays operator-editable from `/admin`. |
| T-24-22 | Elevation of Privilege | AWS SDK eager-loaded into every server module graph via a top-level scheduler import | mitigate | Both dispatch branches use dynamic `await import()`, matching every other branch, keeping the AWS client out of unrelated module graphs and out of any hot path. Grep-asserted. |
| T-24-23 | Tampering | active-content injection through the tile logo asset | mitigate | `public/logos/route53.svg` is hand-authored from primitive shapes with no `<script>` element, no external reference, and no embedded raster data. Grep-asserted. |
| T-24-24 | Repudiation | operator confusion causing sync to be silently stopped or silently left running | mitigate | The deliberate absence of the PAX8 disable gate is recorded in an inline comment above the branches and in this plan's `must_haves`, so the D-10 behavior is discoverable at the code site rather than only in planning artifacts. |
| T-24-05 | Tampering / Spoofing | live DNS record content | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with post-hoc audit only. This plan adds no write path. |
</threat_model>
<verification>
- `npx tsc --noEmit --pretty` exits 0
- `npm test` full suite green
- `npm run build` succeeds (the scheduler is imported at server startup; a bad dynamic import surfaces here)
- `/admin/sync` renders 11 tiles including AWS Route 53, and the tile links to `/admin/sync/route53`
- `SELECT id, cron_expression, is_enabled FROM sync_schedules WHERE sync_type LIKE 'route53%'` returns two rows, both `is_enabled = false`
</verification>
<success_criteria>
- `route53-incremental` and `route53-full` present in the sync_type union, defaultSchedules, and the dispatch chain
- Both schedules seeded disabled with non-colliding cron expressions
- Dispatch branches gate on `isRoute53Configured()` only, never on `integration_settings` (D-10)
- Route 53 tile visible on `/admin/sync` with a valid COLOR_MAP key and a working logo asset
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-06-SUMMARY.md` when done.
Record the final cron expressions chosen for both schedules.
</output>

View file

@ -0,0 +1,368 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 07
type: execute
wave: 4
depends_on: ["24-04", "24-05", "24-06"]
files_modified:
- app/admin/sync/route53/page.tsx
- components/admin/route53/record-editor-dialog.tsx
autonomous: false
requirements: [SC-2, SC-4, SC-6]
must_haves:
truths:
- "SC-6: /admin/sync/route53 renders zones, records, and change history using the existing DataTable / DetailModal / SyncScheduler vocabulary, matching /admin/sync/veeam's structure"
- "SC-2: An admin can create, edit, and delete a DNS record from the page, and the change reaches AWS Route 53 through the plan 24-05 write routes"
- "SC-4: Record-level change history is visible per record, showing before/after values, the actor, the timestamp, and whether the change came from Pulse (pulse_crud) or was detected externally (sync_detected_drift)"
- "D-01: The record-type selector offers only A, AAAA, CNAME, MX, TXT, SRV — and NS/SOA rows render as read-only with no edit or delete control (the server-side 400 remains the real gate; this is UI consistency, not the enforcement point)"
- "D-03: The delete control executes immediately after a single confirmation dialog — the confirmation is a misclick guard, not an approval workflow, and there is no pending/approval state"
- "T-24-20: The save/delete control is disabled while a request for that zone is in flight, avoiding PriorRequestNotComplete from double-submits"
artifacts:
- path: "app/admin/sync/route53/page.tsx"
provides: "Zones / Records / History / Sync tabs for Route 53"
min_lines: 200
- path: "components/admin/route53/record-editor-dialog.tsx"
provides: "Create/edit record form and delete confirmation"
key_links:
- from: "app/admin/sync/route53/page.tsx"
to: "/api/route53/zones"
via: "fetch in useEffect"
pattern: "fetch\\('/api/route53/zones"
- from: "components/admin/route53/record-editor-dialog.tsx"
to: "/api/route53/zones/[zoneId]/records"
via: "fetch POST/PATCH/DELETE"
pattern: "method: '(POST|PATCH|DELETE)'"
- from: "app/admin/sync/route53/page.tsx"
to: "/api/route53/zones/[zoneId]/records/[recordId]/history"
via: "fetch on history drill-down"
pattern: "/history"
---
<objective>
Build the `/admin/sync/route53` detail page (D-09): zones, records, per-record change
history, and the record editor that drives the plan 24-05 CRUD routes — then run the
end-to-end human verification for the whole phase.
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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.md
<interfaces>
<!-- API contract from plan 24-05. Use directly. All responses are camelCase. -->
GET /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 }
<!-- Existing shared components (do not rebuild) -->
components/admin/DataTable.tsx — @tanstack/react-table wrapper: <DataTable columns={} data={} />
components/admin/DetailModal.tsx — formatted/raw tab detail modal
components/admin/SyncScheduler.tsx — schedule editor used by every /admin/sync/<x> page
components/ui/* — shadcn primitives (Button, Tabs, Dialog, Input, Select, Badge)
lib/hooks/use-user-timezone.ts — useUserTimezone() for timestamp rendering
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Zones, records, and history page shell</name>
<files>app/admin/sync/route53/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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`:
1. **Zones**`DataTable` over `GET /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. Show `authoritativeNameServers` in a
`DetailModal` drill-down so an operator can compare against the health check's finding
from plan 24-04.
2. **Records** — a zone selector (shadcn `Select` populated from the zones response) plus a
`DataTable` over `GET /api/route53/zones/{zoneId}/records`. Columns: name, type (badge),
TTL, values (join `resourceRecords` values with a comma, truncated with a title
attribute), and an actions cell. Render record values as plain text through React's
default escaping — no `dangerouslySetInnerHTML` anywhere 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 whose `type` is 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 a `title` explaining 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.
3. **History**`DataTable` over the currently-selected record's
`GET /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 distinguish `pulse_crud` from `sync_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 opens `DetailModal` with 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").
4. **Schedule** — embed `components/admin/SyncScheduler.tsx` the same way
`app/admin/sync/veeam/page.tsx` does, so the `route53-incremental` and `route53-full`
schedules 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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty && npm run build</automated>
</verify>
<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>
<done>The detail page renders zones, records, history with source badges, and the schedule editor, degrading cleanly when unconfigured.</done>
</task>
<task type="auto">
<name>Task 2: Record editor dialog with create, edit, and immediate delete</name>
<files>components/admin/route53/record-editor-dialog.tsx</files>
<read_first>
- app/admin/sync/route53/page.tsx (as written in Task 1 — the props the dialog receives)
- components/ui/dialog.tsx, components/ui/select.tsx, components/ui/input.tsx (shadcn primitive APIs available in this project)
- lib/services/route53-record-validation.ts (WRITABLE_RECORD_TYPES — the UI selector must offer exactly this list, and the same TTL/value constraints so client and server agree)
- app/api/route53/zones/[zoneId]/records/[recordId]/route.ts (plan 24-05 — the exact success/failure response envelope to handle)
- components/admin/DetailModal.tsx (existing dialog styling conventions to match)
</read_first>
<action>
Create `components/admin/route53/record-editor-dialog.tsx` exporting a `RecordEditorDialog`
PascalCase component from the kebab-case file (CLAUDE.md naming rule).
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` — shadcn `Select` whose options are exactly `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV`
(D-01). `NS` and `SOA` must not appear as options. Read-only in edit mode for the same
reason as `name`.
- `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 — prevents `PriorRequestNotComplete` from a double-click against the same hosted
zone). On a 2xx response call `toast.success` including the returned `propagationStatus`
(`INSYNC` → "Propagated", `PENDING` → "Submitted — propagating"), then `onSaved()` and close.
On 400 / 409 / 502 render the response body's `message` field inline in the dialog AND as a
`toast.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()`.
</action>
<verify>
<automated>npx tsc --noEmit --pretty && npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0 && echo PASS</automated>
</verify>
<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>
<done>An admin can create, edit, and delete records from the UI; double-submits are blocked; no approval workflow was introduced.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: End-to-end phase verification</name>
<action>
Pause execution and present the nine verification steps below to the developer verbatim. Confirm the dev server is running on port 3100 first. Run any command the developer asks you to run on their behalf. Do not mark the phase complete until they respond. Record the per-step outcomes in the SUMMARY and update 24-VALIDATION.md's Manual-Only Verifications rows with the observed results.
</action>
<what-built>
The full Phase 24 stack: `@aws-sdk/client-route-53` + migration 102 + factory (24-01);
zone/record sync with drift history (24-02); record validation and the audit lifecycle
(24-03); the health check with the D-12 NS-delegation comparison (24-04); the
`/api/route53/*` read and CRUD routes (24-05); scheduler entries and the `/admin/sync`
tile (24-06); and the `/admin/sync/route53` detail page with the record editor (this plan).
Everything below is already automated — this checkpoint confirms the live round-trip
against a real AWS account, which cannot be safely automated (see 24-VALIDATION.md's
Manual-Only Verifications table).
</what-built>
<how-to-verify>
1. **Sync (SC-1).** Visit `http://localhost:3100/admin/sync` — confirm an "AWS Route 53" tile
appears with a working logo. Click it, then click "Sync Now". Expect the zone and record
counts to become non-zero and a `sync_history` row with `entity_type='route53'` and
`status='completed'` to appear in the Schedule/status area.
2. **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 value
`phase24-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 with
`change_action='create'`, `source='pulse_crud'`, and your email as the actor.
3. **Update.** Edit that record's value to `phase24-verification-updated`. Confirm the change
in the AWS console and a second history row with `change_action='update'` whose
`before_value` holds the original value.
4. **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.
5. **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 with `status='committed'` from steps 2-4, each with your email.
6. **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"}]}'`
Expect `400`. Then force a genuine AWS-side failure (for example a CNAME at the zone apex,
which Route 53 rejects) and confirm the response is `502` and a `route53_audit_log` row
with `status='failed'` and a non-empty, sanitized `error_message` exists.
7. **Auth gating (D-04, T-24-02).** Sign in as a `user`-role account (or reuse its session
cookie) and repeat the create curl. Expect `403`. Confirm `GET /api/route53/zones` still
returns 200 for that same non-admin session (reads are `requireAuth()`, writes are
`requireAdmin()`).
8. **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.
9. **Health check (D-12, SC-6).** Visit `/admin/integrations` (or whichever page renders
`checkIntegrationHealth`) 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
shows `disabled` **while a manual `POST /api/route53/sync` still 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.
</how-to-verify>
<resume-signal>
Reply "approved" if all nine steps behave as described, or list the step numbers that failed
with what you observed instead.
</resume-signal>
</task>
</tasks>
<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>
<verification>
- `npm run build` succeeds; `npx tsc --noEmit --pretty` exits 0; `npm test` full suite green
- `/admin/sync` shows the AWS Route 53 tile; `/admin/sync/route53` renders four tabs
- Checkpoint steps 1-9 all pass, with results recorded in the SUMMARY
- 24-VALIDATION.md's three Manual-Only rows (write-route auth gating, live AWS round-trip,
DNS-egress assumption) are all exercised by checkpoint steps 7, 2-4, and 9 respectively
</verification>
<success_criteria>
- `/admin/sync/route53` renders 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_crud` from `sync_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/integrations` suppresses the health row without stopping sync (D-10)
</success_criteria>
<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-07-SUMMARY.md` when done.
Record the checkpoint results per step, and update
`.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md`'s
Manual-Only Verifications rows with their observed outcomes.
</output>

View file

@ -1,10 +1,11 @@
---
phase: 24
slug: aws-route-53-dns-sync-track-changes-crud-operations-full-aud
status: draft
nyquist_compliant: false
status: planned
nyquist_compliant: true
wave_0_complete: false
created: 2026-08-05
updated: 2026-08-05
---
# Phase 24 — Validation Strategy
@ -18,18 +19,24 @@ created: 2026-08-05
| 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) |
| **Config file** | `vitest.config.ts` (`include: ['lib/**/*.test.ts']`, `environment: 'node'`, `globals: false`) |
| **Quick run command** | `npx vitest run <touched-test-file>` |
| **Full suite command** | `npm test` |
| **Estimated runtime** | ~10 seconds (small existing suite) |
**Structural constraint driving the plan layout:** `vitest.config.ts` includes only
`lib/**/*.test.ts`. Nothing under `app/api/**` can be unit-tested. Every plan therefore
places its testable logic (record-key derivation, drift classification, the D-01 allowlist,
AWS error sanitization, change-batch construction, propagation polling, NS comparison) in a
`lib/services/` module that the route handlers call, rather than inline in a route file.
---
## Sampling Rate
- **After every task commit:** Run `npx vitest run <touched-test-file>`
- **After every plan wave:** Run `npm test`
- **Before `/gsd:verify-work`:** Full suite must be green
- **After every task commit:** `npx vitest run <touched-test-file>`
- **After every plan wave:** `npm test`
- **Before `/gsd:verify-work`:** full suite green + `npx tsc --noEmit --pretty` clean
- **Max feedback latency:** 15 seconds
---
@ -38,43 +45,71 @@ created: 2026-08-05
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| TBD | TBD | TBD | SC-3/SC-4 (audit + history correctness) | — | `route53_record_history` gets a row with correct `source` tag (pulse_crud vs sync_detected_drift) for CRUD vs. drift | unit | `npx vitest run lib/services/route53-sync-service.test.ts` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | D-12 (NS delegation health check) | — | NS-list normalization (case, trailing dot) and mismatch detection classify correctly | unit | `npx vitest run lib/services/integration-health.test.ts` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | D-01 (write-type allowlist) | T-24-01 | Server-side rejection of NS/SOA record writes with 400, before constructing `ChangeResourceRecordSetsCommand` | unit | `npx vitest run lib/services/route53-sync-service.test.ts` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SC-2 (CRUD write-back auth gating) | T-24-02 | `requireAdmin()` returns 401/403 for non-admin sessions on write routes | manual / smoke | none automated — matches existing project convention (see `22-VERIFICATION.md` precedent) | ❌ — manual by convention | ⬜ pending |
| 24-01-T1 | 24-01 | 1 | SC-3, SC-4, SC-5 | T-24-06, T-24-SC | Route 53 schema exists with `source` and `status` CHECK constraints; no AWS secret in committed `.env` | schema/grep gate | `grep -c 'CREATE TABLE IF NOT EXISTS route53_' migrations/102_route53_tables.sql` = 4 and `grep -c '^AWS_' .env` = 0 | created by task | ⬜ pending |
| 24-01-T2 | 24-01 | 1 | SC-5 | T-24-06 | `isRoute53Configured()` false without creds; `getRoute53Client()` throws; no explicit `credentials:` object | unit | `npx vitest run lib/services/route53-factory.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-01-T3 | 24-01 | 1 | SC-5 | T-24-08, T-24-09 | BWS key names confirmed; creds present in container; DNS egress reachable; IAM least-privilege | manual / checkpoint | none automated — blocking `checkpoint:human-verify` | ❌ manual by necessity | ⬜ pending |
| 24-02-T1 | 24-02 | 2 | SC-1, SC-4 | — | Record-key derivation and recordset normalization are order-insensitive, so equal recordsets never register as drift | unit | `npx vitest run lib/services/route53-record-key.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-02-T2 | 24-02 | 2 | SC-1 | T-24-03 | Pagination + soft-delete; errors logged as `error.message` only, never the raw AWS error object | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -c 'integration_settings' lib/services/route53-sync-service.ts) -eq 0` | created by task | ⬜ pending |
| 24-02-T3 | 24-02 | 2 | SC-4 | T-24-04, T-24-10 | Drift produces exactly one correctly-tagged `sync_detected_drift` history row per changed record; equal recordsets and initial import produce none | unit | `npx vitest run lib/services/route53-sync-service.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-03-T1 | 24-03 | 2 | SC-3 | **T-24-01**, T-24-03, T-24-12 | Closed six-type allowlist rejects `NS`/`SOA`/`ns`/`CAA` with 400; `sanitizeAwsError` redacts AKIA key ids, `arn:aws:*`, and 12-digit account ids | unit | `npx vitest run lib/services/route53-record-validation.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-03-T2 | 24-03 | 2 | SC-3 | T-24-04, T-24-07 | Audit row inserted `status='pending'` before any AWS call; `markAuditFailed` binds a sanitized message; mirror deletes are soft-only | unit (mocked pg) | `npx vitest run lib/services/route53-write-persistence.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-04-T1 | 24-04 | 2 | SC-6 (D-12) | T-24-13, T-24-15 | NS normalization is case/trailing-dot insensitive; empty authoritative list yields no false alarm; empty live answer yields a mismatch; `setServers` only on a dedicated `Resolver` instance | unit | `npx vitest run lib/services/route53-dns-delegation.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-04-T2 | 24-04 | 2 | SC-6 | T-24-03, T-24-16 | `route53` present in the health aggregate; auth-probe errors sanitized; delegation-step failure cannot abort `Promise.all` | integration (curl) + typecheck | `npx tsc --noEmit --pretty && curl -s localhost:3100/api/admin/integration-health \| grep -q '"key":"route53"'` | modified by task | ⬜ pending |
| 24-05-T1 | 24-05 | 3 | SC-2 | **T-24-01**, T-24-19, T-24-20 | `buildChangeBatch` throws on NS/SOA (defence in depth); `pollChangeStatus` is bounded and stops after timeout; the 30-minute SDK waiter is absent | unit | `npx vitest run lib/services/route53-change-submit.test.ts` | Wave 0 (created by task) | ⬜ pending |
| 24-05-T2 | 24-05 | 3 | SC-4, SC-6 | **T-24-02**, T-24-18 | Every read route gated by `requireAuth()`; all SQL parameter-bound; no `integration_settings` gating (D-10) | grep gate + curl | `test $(grep -rl 'requireAuth\|requireAdmin' app/api/route53 \| wc -l) -eq 4` and unauthenticated `curl` on `/api/route53/zones` returns 401 | created by task | ⬜ pending |
| 24-05-T3 | 24-05 | 3 | SC-2, SC-3 | **T-24-01**, **T-24-02**, T-24-03, T-24-04, T-24-17 | `validateRecordWrite` and `createPendingAuditLog` both precede `submitRecordChange`; history written only on success; failures return 502 with a sanitized message; no staged-approval state | source-order gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -rc 'pending_approval\|requiresConfirmation\|confirmToken' app/api/route53/ \| awk -F: '{s+=$2} END {print s}') -eq 0` | created by task | ⬜ pending |
| 24-05-T3m | 24-05 | 3 | SC-2 (D-04) | **T-24-02** | `requireAdmin()` returns 403 for a `user`-role session hitting a write route directly | manual / smoke | none automated — see Manual-Only table row 1 | ❌ manual by convention | ⬜ pending |
| 24-06-T1 | 24-06 | 3 | SC-1, SC-6 | T-24-21, T-24-22, T-24-24 | Both sync types dispatch via dynamic import and gate on `isRoute53Configured()` only, never on `integration_settings` (D-10) | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts \| grep -c integration_settings) -eq 0` | modified by task | ⬜ pending |
| 24-06-T2 | 24-06 | 3 | SC-6 | T-24-23 | Tile entry uses a valid `COLOR_MAP` key; logo asset contains no `script` or `xlink:href` | grep gate + build | `npm run build && test $(grep -ci 'script\|xlink:href' public/logos/route53.svg) -eq 0` | created by task | ⬜ pending |
| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | created by task | ⬜ pending |
| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | created by task | ⬜ pending |
| 24-07-T3 | 24-07 | 4 | SC-1..SC-6 | all | Full live round-trip: sync, create/update/delete against real AWS, audit completeness, failure logging, auth gating, drift detection, D-12 health, D-10 display-only disable | manual / checkpoint | none automated — blocking `checkpoint:human-verify`, 9 steps | ❌ manual by necessity | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Plan/Wave/Task IDs are TBD — the planner has not yet assigned plan numbers. Update this table's Task ID/Plan/Wave columns once `PLAN.md` files exist (or leave for `gsd-plan-checker` to cross-reference against actual plan task IDs).*
**Sampling continuity check:** no 3 consecutive tasks lack an automated verify. The two
checkpoint tasks (24-01-T3, 24-07-T3) are each adjacent to tasks carrying automated
commands, and both are terminal within their plan.
---
## Wave 0 Requirements
- [ ] `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/integration-health.test.ts` — first test file for any integration's health check; cover the new Route 53 NS-delegation mismatch detection logic (no existing precedent — this is a new test file, not an extension)
- [ ] `lib/services/route53-factory.test.ts``isRoute53Configured()` true/false branches (optional, low priority; mirrors that `veeam-factory.ts` has no test file today, but this is the first AWS-credential-shaped factory in the codebase)
All Wave 0 test files are created by the task that needs them, inside the plan that owns the
module under test — no separate scaffolding plan is required, because every test target is a
new file rather than an extension of untested existing code.
- [ ] `lib/services/route53-factory.test.ts` — plan 24-01 Task 2 (`isRoute53Configured()` branches)
- [ ] `lib/services/route53-record-key.test.ts` — plan 24-02 Task 1 (key derivation, normalization, order-insensitive equality, drift classification)
- [ ] `lib/services/route53-sync-service.test.ts` — plan 24-02 Task 3 (drift history row shape and `source` tagging)
- [ ] `lib/services/route53-record-validation.test.ts` — plan 24-03 Task 1 (D-01 closed allowlist, TTL/value shape, AWS error sanitizer)
- [ ] `lib/services/route53-write-persistence.test.ts` — plan 24-03 Task 2 (pending/committed/failed SQL contract, soft-delete only)
- [ ] `lib/services/route53-dns-delegation.test.ts` — plan 24-04 Task 1 (NS normalization + mismatch detection; first health-check test file in this codebase)
- [ ] `lib/services/route53-change-submit.test.ts` — plan 24-05 Task 1 (change-batch construction, retry classification, bounded poll)
- [ ] No framework install needed — vitest is already configured project-wide
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|--------------------|
| Write-route auth gating (401/403 for non-admin) | SC-2 / D-04 | `requireAdmin()`/`requirePermission()` are Better Auth session-dependent; this codebase has no precedent for testing route auth gating in isolation — historically verified by manual click-through (see `22-VERIFICATION.md`) | Sign in as a `user`-role account, hit `POST /api/route53/records` directly (curl or browser devtools) with a valid record payload, confirm 403. Repeat as `admin` and confirm success. |
| Live AWS write-back round-trip (create/update/delete a real record) | SC-2 | Cannot be safely automated against a real AWS account/hosted zone in CI; requires a real Route 53 zone and live credentials | Using a disposable test record in a real (or sandbox) hosted zone: create via Pulse UI, confirm it appears in the AWS console within the `GetChange` poll window; update it; delete it; confirm `route53_audit_log` has 3 rows with correct before/after values. |
| DNS-egress-in-production assumption (D-12 health check) | D-12 | Whether outbound UDP/53 to public resolvers is permitted from the production container network is unverifiable from the repo (flagged as Open Question 3 in `24-RESEARCH.md`) | Deploy to production/staging, trigger the Route 53 health check, confirm the live NS lookup resolves rather than timing out. If it times out, the health check needs a fallback (DoH) per the research's open question. |
| Behavior | Requirement | Covered By | Why Manual | Test Instructions |
|----------|-------------|------------|------------|--------------------|
| Write-route auth gating (401/403 for non-admin) | SC-2 / D-04 / T-24-02 | 24-07 Task 3, step 7 | `requireAdmin()`/`requireAuth()` are Better Auth session-dependent; this codebase has no precedent for testing route auth gating in isolation — historically verified by manual click-through (see `22-VERIFICATION.md`) | Sign in as a `user`-role account, `POST /api/route53/zones/<zone>/records` with a valid payload, confirm 403. Confirm `GET /api/route53/zones` still returns 200 for the same session. Repeat the POST as `admin` and confirm success. |
| Live AWS write-back round-trip (create/update/delete a real record) | SC-2 / SC-3 / SC-4 | 24-07 Task 3, steps 2-6 | Cannot be safely automated against a real AWS account/hosted zone in CI; requires a real Route 53 zone and live credentials | Using a disposable TXT record in a real hosted zone: create via the Pulse UI, confirm in the AWS console; update; delete. Confirm `route53_audit_log` has 3 `committed` rows with correct before/after and actor, and `route53_record_history` has 3 `pulse_crud` rows. Then force an AWS-side rejection (apex CNAME) and confirm a `failed` row with a sanitized `error_message` and an HTTP 502 response. |
| DNS-egress-in-production assumption (D-12 health check) | D-12 / T-24-13 | 24-01 Task 3, step 3 and 24-07 Task 3, step 9 | Whether outbound UDP/53 to public resolvers is permitted from the production container network is unverifiable from the repo (Open Question 3 / Assumption A3 in `24-RESEARCH.md`) | Run the `dns.Resolver().setServers(['1.1.1.1','8.8.8.8']).resolveNs('google.com')` one-liner inside the container (exact command in plan 24-01 Task 3). `EGRESS-OK` means the Node `dns` path is viable; `EGRESS-BLOCKED` requires plan 24-04 to use the DoH-over-HTTPS fallback. |
| D-10 display-only disable behavior | D-10 | 24-07 Task 3, step 9 | The failure mode (disabling the toggle silently stopping sync, replicating PAX8's blocking exception) is only observable end-to-end across the health cache, the scheduler, and the sync route | Toggle `route53` off at `/admin/integrations`, wait for the 5-minute health cache (or let the PATCH clear it), confirm the health row reads `disabled`, then confirm `POST /api/route53/sync` still succeeds and returns `{ ok: true }`. |
| BWS secret key names / IAM least privilege | SC-5 / T-24-08 | 24-01 Task 3, steps 1, 2, 4 | BWS project contents live in Bitwarden's cloud, and IAM policy scope lives in the AWS console — neither is inspectable from this repo | See plan 24-01 Task 3's `how-to-verify` steps 1, 2, and 4. Verification commands print only `SET`/`unset` presence markers, never secret values. |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 15s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have an `<automated>` verify or an explicit manual/checkpoint justification
- [x] Sampling continuity: no 3 consecutive tasks without an automated verify
- [x] Wave 0 covers all MISSING references — every test file is created by the task that needs it
- [x] No watch-mode flags in any command
- [x] Feedback latency < 15s (all unit commands target a single file; the full suite is ~10s)
- [x] Every grep gate that counts occurrences targets source constructs, not comment prose
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** ready for execution