/** * AWS Route 53 DNS sync — CRUD write-back persistence. * * Implements the pending -> committed/failed audit lifecycle, the single * most important pattern in this phase (lifted from * lib/services/analyzer/asset-audit/persistence.ts's createPendingWrite / * markWriteCommitted / markWriteFailed shape, itself following * migrations/075_itglue_audit.sql's itglue_writes precedent). * * Discipline (24-RESEARCH.md Pattern 3, D-07): an audit row is created with * status='pending' BEFORE any AWS command is constructed. It is then * transitioned to 'committed' or 'failed' after the AWS call resolves. * Callers must never construct a ChangeResourceRecordSetsCommand without an * audit row already in flight. */ import postgresClient from '@/lib/services/postgres-client'; import { sanitizeAwsError } from '@/lib/services/route53-record-validation'; import type { Route53RecordValue } from '@/lib/types/route53'; const LOG_PREFIX = '[ROUTE53-WRITE]'; // ============================================================================ // Audit lifecycle (route53_audit_log) // ============================================================================ export interface CreatePendingAuditLogInput { operation: 'create' | 'update' | 'delete'; zoneId: string; recordKey: string; recordName: string; recordType: string; beforeValue: unknown; afterValue: unknown; performedByUserId: string | null; performedByEmail: string | null; } /** * Insert a 'pending' audit row. Must be called and awaited BEFORE any AWS * ChangeResourceRecordSetsCommand is constructed — that ordering is the * whole point of this pattern: no write to Route 53 can occur without an * audit row already in flight (D-07, SC-3). This write is not best-effort — * let it throw so a DB failure fails the request rather than silently * allowing an unlogged DNS mutation (T-24-07). */ export async function createPendingAuditLog( input: CreatePendingAuditLogInput ): Promise<{ id: string }> { const res = await postgresClient.query<{ 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, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, 'pending') RETURNING id::text AS id`, [ input.operation, input.zoneId, input.recordKey, input.recordName, input.recordType, JSON.stringify(input.beforeValue ?? null), JSON.stringify(input.afterValue ?? null), input.performedByUserId, input.performedByEmail, ] ); return { id: res.rows[0].id }; } /** * Transition an audit row to 'committed' after a successful AWS call. Not * best-effort — let it throw. */ export async function markAuditCommitted( id: string, awsChangeId: string | null, awsChangeStatus: string | null, awsResponse: unknown ): Promise { await postgresClient.query( `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`, [id, awsChangeId, awsChangeStatus, JSON.stringify(awsResponse ?? null)] ); } /** * Transition an audit row to 'failed' after an unsuccessful AWS call. * D-07 + T-24-03: the error is always passed through sanitizeAwsError first * — never pass a raw error object or JSON.stringify(err) into error_message. * Not best-effort — let it throw. */ export async function markAuditFailed(id: string, err: unknown): Promise { await postgresClient.query( `UPDATE route53_audit_log SET status = 'failed', completed_at = NOW(), error_message = $2 WHERE id = $1`, [id, sanitizeAwsError(err)] ); } // ============================================================================ // pulse_crud history (route53_record_history) // ============================================================================ export interface InsertPulseCrudHistoryInput { zoneId: string; recordKey: string; recordName: string; recordType: string; changeAction: 'create' | 'update' | 'delete'; beforeValue: unknown; afterValue: unknown; changedByUserId: string | null; changedByEmail: string | null; auditLogId: string; } /** * Insert a 'pulse_crud' history row. * * IMPORTANT: callers must invoke this ONLY after markAuditCommitted. A * failed AWS call changed nothing on AWS's side, so it gets an audit row * (for traceability of the attempt) but must NOT get a history row (which is * a timeline of resolved, actually-applied changes). See 24-RESEARCH.md * Pattern 3. */ export async function insertPulseCrudHistory( input: InsertPulseCrudHistoryInput ): Promise { await postgresClient.query( `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, audit_log_id) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, 'pulse_crud', $8, $9, $10)`, [ input.zoneId, input.recordKey, input.recordName, input.recordType, input.changeAction, JSON.stringify(input.beforeValue ?? null), JSON.stringify(input.afterValue ?? null), input.changedByUserId, input.changedByEmail, input.auditLogId, ] ); } // ============================================================================ // Mirror refresh (route53_records) — best-effort // ============================================================================ export interface UpsertMirrorRecordInput { recordKey: string; zoneId: string; name: string; type: string; setIdentifier: string | null; ttl: number | null; resourceRecords: Route53RecordValue[] | null; aliasTarget: Record | null; rawPayload: unknown; } /** * Best-effort refresh of route53_records after a committed write so the * admin UI reflects the change before the next scheduled sync. A failure * here is logged but never thrown — the AWS write already succeeded and the * next incremental sync reconciles the mirror regardless. */ export async function upsertMirrorRecord(input: UpsertMirrorRecordInput): Promise { try { await postgresClient.query( `INSERT INTO route53_records (record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target, raw_payload, synced_at, updated_at, is_deleted, deleted_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, NOW(), NOW(), false, NULL) ON CONFLICT (record_key) DO UPDATE SET zone_id = EXCLUDED.zone_id, name = EXCLUDED.name, type = EXCLUDED.type, set_identifier = EXCLUDED.set_identifier, ttl = EXCLUDED.ttl, resource_records = EXCLUDED.resource_records, alias_target = EXCLUDED.alias_target, raw_payload = EXCLUDED.raw_payload, synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`, [ input.recordKey, input.zoneId, input.name, input.type, input.setIdentifier, input.ttl, JSON.stringify(input.resourceRecords ?? null), JSON.stringify(input.aliasTarget ?? null), JSON.stringify(input.rawPayload ?? null), ] ); } catch (err) { console.error(`${LOG_PREFIX} upsertMirrorRecord failed (best-effort):`, sanitizeAwsError(err)); } } /** * Soft-delete a mirror record. Never hard-delete: route53_record_history * references record_key and the ledger is unbounded by design (D-08). Best * effort — logged, never thrown. */ export async function softDeleteMirrorRecord(recordKey: string): Promise { try { await postgresClient.query( `UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE record_key = $1`, [recordKey] ); } catch (err) { console.error(`${LOG_PREFIX} softDeleteMirrorRecord failed (best-effort):`, sanitizeAwsError(err)); } } // ============================================================================ // Mirror read (route53_records) // ============================================================================ export interface MirrorRecordRow { recordKey: string; zoneId: string; name: string; type: string; setIdentifier: string | null; ttl: number | null; resourceRecords: Route53RecordValue[] | null; aliasTarget: Record | null; } interface RawMirrorRecordRow { record_key: string; zone_id: string; name: string; type: string; set_identifier: string | null; ttl: number | null; resource_records: Route53RecordValue[] | null; alias_target: Record | null; } /** * Load the current mirror row for a record key, if present and not * soft-deleted. Supplies the before_value for updates/deletes and, * critically, the exact TTL and value set a Route 53 DELETE action requires * to match — a DELETE with a mismatched TTL or value set fails or targets * the wrong recordset (24-RESEARCH.md Pitfall 3). */ export async function loadMirrorRecord(recordKey: string): Promise { const res = await postgresClient.query( `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`, [recordKey] ); if (res.rowCount === 0) return null; const row = res.rows[0]; return { recordKey: row.record_key, zoneId: row.zone_id, name: row.name, type: row.type, setIdentifier: row.set_identifier, ttl: row.ttl, resourceRecords: row.resource_records, aliasTarget: row.alias_target, }; }