26 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud | 01 | execute | 1 |
|
false |
|
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-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.mdlib/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 ?? ''}
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.
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
<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>
Migration file created with all 4 tables + seed row; AWS SDK installed; no committed migration edited; no AWS secret written to .env.
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) |.
npx vitest run lib/services/route53-factory.test.ts && npx tsc --noEmit --pretty
<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>
Types and factory exist, factory tests green, type-check clean, no explicit credentials wiring, CLAUDE.md documents the AWS_* exception.
-
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. -
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 startingEGRESS-OK:. If it printsEGRESS-BLOCKED, plan 24-04 must use the DoH-over-HTTPS fallback described in 24-RESEARCH.md's Alternatives Considered instead of Node'sdnsmodule. -
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. 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.
<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> |
<success_criteria>
@aws-sdk/client-route-53present inpackage.jsondependenciesmigrations/102_route53_tables.sqldefines route53_zones, route53_records, route53_record_history, route53_audit_log and seedsintegration_settings('route53')lib/types/route53.tsandlib/services/route53-factory.tsexport the contracts listed in<interfaces>- Factory tests pass; type-check clean
- No AWS secret value written to
.envor any committed file - Checkpoint answered: BWS key names confirmed, credentials present in container, DNS egress result known </success_criteria>