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

36 KiB

Phase 24: AWS Route 53 DNS Sync - Pattern Map

Mapped: 2026-08-05 Files analyzed: 12 new, 3 modified Analogs found: 15 / 15

File Classification

New/Modified File Role Data Flow Closest Analog Match Quality
lib/services/route53-factory.ts service (factory) request-response lib/services/veeam-factory.ts / lib/services/datto-rmm-factory.ts exact
lib/services/route53-sync-service.ts service (sync) CRUD (batch upsert) lib/services/veeam-sync-service.ts exact
lib/services/route53-client-helpers.ts (optional) utility transform lib/services/veeam-sync-service.ts (FK-safety-set helpers, inline) role-match
lib/types/route53.ts model (types) lib/types/veeam.ts (barrel domain types) exact
migrations/102_route53_tables.sql migration migrations/091_pax8_tables.sql (dedicated integration schema) + migrations/075_itglue_audit.sql (audit/write ledger shape) exact
app/api/route53/sync/route.ts route request-response (fire-and-forget trigger) app/api/pax8/sync/route.ts exact
app/api/route53/zones/route.ts route CRUD (read) app/api/pax8/companies/route.ts role-match
app/api/route53/zones/[zoneId]/records/route.ts route CRUD (list + create) app/api/analyzer/itglue/applications/route.ts (list) + .../apply/route.ts (write) role-match
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts route CRUD (update/delete) app/api/analyzer/itglue/applications/[id]/apply/route.ts exact
app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts route CRUD (read, append-only ledger) app/api/analyzer/itglue/applications/[id]/audit/route.ts role-match
app/admin/sync/route53/page.tsx component (page) request-response app/admin/sync/veeam/page.tsx exact
app/admin/sync/page.tsx (modified) component (page, tile list) request-response itself — extend INTEGRATIONS array in place exact
lib/services/sync-scheduler.ts (modified) service (scheduler) event-driven (cron) itself — extend sync_type union + defaultSchedules + dispatch branch exact
lib/services/integration-health.ts (modified) service (health check) request-response itself — add checkRoute53() alongside checkDattoRmm()/checkItglue() exact
lib/services/route53-write-persistence.ts (helper, optional split) service CRUD lib/services/analyzer/asset-audit/persistence.ts exact

Pattern Assignments

lib/services/route53-factory.ts (service, request-response)

Analog: lib/services/veeam-factory.ts (singleton client + config check), cross-checked against lib/services/datto-rmm-factory.ts (multi-var config check)

Full file for reference (lib/services/veeam-factory.ts, lines 1-41):

import { VeeamClient, VeeamClientConfig } from './veeam-client';

let veeamClientInstance: VeeamClient | null = null;

export function isVeeamConfigured(): boolean {
  return !!(process.env.VEEAM_VSPC_URL && process.env.VEEAM_VSPC_API_KEY);
}

export function getVeeamClient(): VeeamClient {
  if (!veeamClientInstance) {
    const config: VeeamClientConfig = {
      baseUrl: process.env.VEEAM_VSPC_URL || '',
      apiKey: process.env.VEEAM_VSPC_API_KEY || '',
    };
    if (!config.baseUrl || !config.apiKey) {
      throw new Error(
        'Veeam VSPC API credentials missing. Please set VEEAM_VSPC_URL and VEEAM_VSPC_API_KEY environment variables.'
      );
    }
    veeamClientInstance = new VeeamClient(config);
    console.log('Veeam VSPC client initialized');
  }
  return veeamClientInstance;
}

export function resetVeeamClient(): void {
  veeamClientInstance = null;
}

Adaptation for Route 53 (per RESEARCH.md's Pattern 1 — literal AWS_* env vars, no custom ROUTE53_* prefix, per Pitfall 1):

import { Route53Client } from '@aws-sdk/client-route-53';

let route53ClientInstance: Route53Client | null = null;

export function isRoute53Configured(): boolean {
  return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY);
}

export function getRoute53Client(): Route53Client {
  if (!route53ClientInstance) {
    if (!isRoute53Configured()) {
      throw new Error(
        'AWS credentials missing. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_REGION) environment variables.'
      );
    }
    // No explicit `credentials` option — @aws-sdk/credential-provider-node's
    // default chain reads AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN
    // from process.env automatically. Region defaults to us-east-1 (Route 53 is
    // global but the SDK still requires a signing region).
    route53ClientInstance = new Route53Client({
      region: process.env.AWS_REGION || 'us-east-1',
    });
  }
  return route53ClientInstance;
}

export function resetRoute53Client(): void {
  route53ClientInstance = null;
}

Do NOT construct an explicit credentials: object — that defeats the SDK default chain BWS relies on (RESEARCH.md Pitfall 1).


lib/services/route53-sync-service.ts (service, CRUD/batch upsert)

Analog: lib/services/veeam-sync-service.ts

Imports pattern (lines 1-19):

import postgresClient from './postgres-client';
import { VeeamClient } from './veeam-client';
import { getVeeamClient } from './veeam-factory';
import { VspcOrganization, /* ... */ } from '@/lib/types/veeam';

For Route 53, mirror with:

import { ListHostedZonesCommand, ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53';
import { getRoute53Client } from './route53-factory';
import postgresClient from './postgres-client';

Class shape + sync-history bookkeeping pattern (lines 40-91, VeeamSyncService constructor + executeSync() header):

export class VeeamSyncService {
  private client: VeeamClient;
  private isSyncing = false;

  constructor(client?: VeeamClient) {
    this.client = client || getVeeamClient();
  }

  isSyncInProgress(): boolean {
    return this.isSyncing;
  }

  async fullSync(triggeredBy: string = 'system'): Promise<VeeamSyncResult> {
    return this.executeSync('full', triggeredBy);
  }

  async incrementalSync(triggeredBy: string = 'system'): Promise<VeeamSyncResult> {
    return this.executeSync('incremental', triggeredBy);
  }

  private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<VeeamSyncResult> {
    if (this.isSyncing) {
      throw new Error('A Veeam sync operation is already in progress');
    }
    this.isSyncing = true;
    const syncId = `veeam-${Date.now()}`;
    const startTime = new Date();
    // ... INSERT INTO sync_history (entity_type, sync_type, status, started_at, ...)
    //     VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id
  }
}

Route53SyncService should follow this exact shape: isSyncInProgress(), fullSync()/incrementalSync() both delegating to a private executeSync(), and the same sync_history bookkeeping insert/update pair (lines 78-89 and 139-149 of the analog). Difference from Veeam: D-06 requires writing route53_record_history (source='sync_detected_drift') whenever a synced record differs from the current mirror row — Veeam's sync has no equivalent diff-and-history step, so this part must be newly composed (diff before INSERT ... ON CONFLICT DO UPDATE, not after).

Step-loop + per-step error isolation pattern (lines 94-120):

const steps: Array<{ name: string; fn: () => Promise<number> }> = [
  { name: 'organizations', fn: () => this.syncOrganizations() },
  { name: 'backup_servers', fn: () => this.syncBackupServers() },
  // ...
];
for (const step of steps) {
  const stepStart = Date.now();
  try {
    const count = await step.fn();
    entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration: Date.now() - stepStart });
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    errors.push(`${step.name}: ${msg}`);
    entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration: Date.now() - stepStart, error: msg });
  }
}

Route 53 steps: { name: 'zones', fn: () => this.syncZones() }, { name: 'records', fn: () => this.syncRecords() } (zones must sync first — records reference zone id).

Upsert-with-FK-safety-set pattern (lines 219-247, syncBackupServers()):

const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));

let count = 0;
for (const s of servers) {
  const orgUid = orgUids.has(s.organizationUid) ? s.organizationUid : null;
  await postgresClient.query(
    `INSERT INTO veeam_backup_servers (instance_uid, name, organization_uid, ..., synced_at)
     VALUES ($1,$2,$3,...,NOW())
     ON CONFLICT (instance_uid) DO UPDATE SET
       name=EXCLUDED.name, organization_uid=EXCLUDED.organization_uid, ...,
       synced_at=NOW(), updated_at=NOW()`,
    [s.instanceUid, s.name, orgUid, /* ... */]
  );
  count++;
}

For route53_records, the FK-safety set is zone ids from route53_zones (loaded once, checked with .has() before insert) — same shape, swap instance_uid for id (Route 53's own ids, not synthetic UUIDs — zone id after stripping /hostedzone/ prefix per RESEARCH.md Pattern 2).

Pagination pattern (RESEARCH.md Pattern 2, ListHostedZonesCommand):

async function syncZones(client = getRoute53Client()) {
  let marker: string | undefined;
  let count = 0;
  do {
    const page = await client.send(new ListHostedZonesCommand({ Marker: marker }));
    for (const z of page.HostedZones ?? []) {
      const zoneId = (z.Id ?? '').replace('/hostedzone/', '');
      await postgresClient.query(
        `INSERT INTO route53_zones (id, name, comment, private_zone, record_count, raw_payload, synced_at)
         VALUES ($1,$2,$3,$4,$5,$6,NOW())
         ON CONFLICT (id) DO UPDATE SET
           name=EXCLUDED.name, comment=EXCLUDED.comment, private_zone=EXCLUDED.private_zone,
           record_count=EXCLUDED.record_count, raw_payload=EXCLUDED.raw_payload,
           synced_at=NOW(), updated_at=NOW()`,
        [zoneId, z.Name, z.Config?.Comment ?? null, !!z.Config?.PrivateZone,
         z.ResourceRecordSetCount ?? 0, JSON.stringify(z)]
      );
      count++;
    }
    marker = page.IsTruncated ? page.NextMarker : undefined;
  } while (marker);
  return count;
}

Error handling pattern (lines 152-173, catastrophic-failure fallback):

} catch (error) {
  const completedAt = new Date();
  const msg = error instanceof Error ? error.message : String(error);
  console.error('[VEEAM-SYNC] Sync failed catastrophically:', msg);
  if (historyId) {
    try {
      await postgresClient.query(
        `UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`,
        [completedAt, msg, historyId]
      );
    } catch (e) { /* ignore */ }
  }
  return { syncId, syncType, status: 'failed', startedAt: startTime, completedAt, duration: completedAt.getTime() - startTime.getTime(), entities: entityResults, errors: [msg] };
} finally {
  this.isSyncing = false;
}

migrations/102_route53_tables.sql (migration)

Analog 1 — dedicated multi-table integration schema: migrations/091_pax8_tables.sql

Header comment + soft-ref convention pattern (lines 1-19, 69-73):

-- PAX8 integration — Postgres schema.
--
-- Lays down the full PAX8 schema Phases 11-14 will populate and consume.
-- ...
CREATE TABLE IF NOT EXISTS pax8_subscriptions (
    id                  UUID PRIMARY KEY,
    pax8_company_id     UUID,  -- soft ref -> pax8_companies(id)
    product_id          UUID,  -- soft ref -> pax8_products(id)
    ...
    raw_payload JSONB,
    synced_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_deleted          BOOLEAN NOT NULL DEFAULT false,
    deleted_at          TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_pax8_subscriptions_is_deleted ON pax8_subscriptions(is_deleted);
CREATE INDEX IF NOT EXISTS idx_pax8_subscriptions_company    ON pax8_subscriptions(pax8_company_id);

route53_zones / route53_records should follow this exact shape (raw_payload JSONB, synced_at/is_deleted/deleted_at audit columns, per-table idx_*_is_deleted index) — Route 53's own ids are the PK (zone id string, recordset composite key), not synthetic UUIDs.

Analog 2 — audit/write ledger with pending→committed/failed lifecycle: migrations/075_itglue_audit.sql

itglue_writes table (lines 60-88) is the direct precedent for route53_audit_log:

CREATE TABLE IF NOT EXISTS itglue_writes (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  audit_id              UUID REFERENCES itglue_asset_audits(id) ON DELETE SET NULL,
  asset_type            TEXT NOT NULL CHECK (asset_type IN ('flexible_asset')),
  asset_id              BIGINT NOT NULL,
  field_name            TEXT NOT NULL,
  before_value          JSONB,
  after_value           JSONB NOT NULL,
  performed_by_user_id  TEXT REFERENCES "user"(id) ON DELETE SET NULL,
  performed_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  status                TEXT NOT NULL
                          CHECK (status IN ('pending','committed','failed','reverted')),
  itglue_response       JSONB,
  error_message         TEXT,
  source_evidence       JSONB
);
CREATE INDEX IF NOT EXISTS ix_itglue_writes_asset
  ON itglue_writes (asset_type, asset_id, performed_at DESC);

Adapt for route53_audit_log: swap asset_type/asset_id for zone_id/record_name/record_type, itglue_response for aws_response (or change_id), and D-07 requires status IN ('pending','committed','failed') plus error_message captured on failure — matches this shape exactly.

route53_record_history (new — D-06's source tag has no existing table precedent; compose from the itglue_writes before/after shape plus an explicit source TEXT NOT NULL CHECK (source IN ('pulse_crud','sync_detected_drift')) column):

CREATE TABLE IF NOT EXISTS route53_record_history (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  zone_id        TEXT NOT NULL REFERENCES route53_zones(id) ON DELETE CASCADE,
  record_name    TEXT NOT NULL,
  record_type    TEXT NOT NULL,
  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_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

integration_settings seed row (per D-10) — extend the existing seed list rather than a new migration touching 081:

-- migrations/102_route53_tables.sql (append)
INSERT INTO integration_settings (key, disabled) VALUES ('route53', false)
ON CONFLICT (key) DO NOTHING;

This mirrors migrations/081_integration_settings.sql lines 29-42's seed pattern exactly (ON CONFLICT (key) DO NOTHING).


app/api/route53/sync/route.ts (route, fire-and-forget trigger)

Analog: app/api/pax8/sync/route.ts (full file, 65 lines)

Disabled-check pattern is D-10 INVERSE — do not copy the disable-blocks-sync check. PAX8 is the only integration where disabled blocks sync (per CLAUDE.md); Route 53 must NOT gate on integration_settings.disabled for POST — D-10 says the toggle is display-only for Route 53. Copy everything else:

import { NextRequest, NextResponse } from 'next/server';
import { getPax8SyncService } from '@/lib/services/pax8-sync-service';
import postgresClient from '@/lib/services/postgres-client';

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => ({}));
  const triggeredBy = body.triggeredBy || 'manual';

  const svc = getPax8SyncService();
  if (svc.isSyncInProgress()) {
    return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
  }

  // Fire and forget — return immediately, sync runs in background
  svc.fullSync(triggeredBy).catch(err =>
    console.error('[Pax8Sync] Background sync error:', err.message)
  );

  return NextResponse.json({ ok: true, message: 'PAX8 sync started' });
}

export async function GET() {
  try {
    const svc = getPax8SyncService();
    const inProgress = svc.isSyncInProgress();
    const counts = await postgresClient.query(`
      SELECT (SELECT COUNT(*) FROM pax8_companies WHERE is_deleted = false) AS companies, ...
    `);
    const history = await postgresClient.query(
      `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 = 'pax8' ORDER BY started_at DESC LIMIT 10`
    );
    return NextResponse.json({ inProgress, counts: counts.rows[0], history: history.rows });
  } catch (err) {
    return NextResponse.json({ error: err instanceof Error ? err.message : 'Failed to get PAX8 sync status' }, { status: 500 });
  }
}

Add requireAuth() at the top of both handlers (PAX8's route has no auth gate — an existing gap, don't replicate it; Route 53's sync route should at minimum require an authenticated session since it's reachable by any logged-in user via the admin UI's page context, matching V4 access control note in RESEARCH.md).


app/api/route53/zones/[zoneId]/records/[recordId]/route.ts (route, CRUD write-back — PATCH/DELETE)

Analog: app/api/analyzer/itglue/applications/[id]/apply/route.ts (full file, 202 lines) — this is the single most important pattern in this phase per RESEARCH.md.

Auth + pending-row-before-external-call pattern (lines 53-121):

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { session, error } = await requirePermission('itglue', 'write');
  if (error) return error;

  const { id: assetId } = await params;
  const body = await request.json().catch(() => ({}));
  const parsed = ApplyAssetSuggestionRequest.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: 'Invalid request body', details: parsed.error.issues }, { status: 400 });
  }

  // Read current state (before_value)
  const asset = await loadAssetRow(assetId);
  if (!asset) return NextResponse.json({ error: 'Asset not found' }, { status: 404 });

  // Insert pending row first so we never write to the external system without
  // an audit row in flight.
  const writeRow = await createPendingWrite({
    audit_id: auditId, asset_type: 'flexible_asset', asset_id: assetId,
    field_name: fieldName, before_value: beforeValue, after_value: suggestedValue,
    performed_by_user_id: userId, source_evidence: sourceEvidence ?? null,
  });

  try {
    const client = getITGlueClient();
    const updated = await client.updateFlexibleAsset(assetId, merged);
    await markWriteCommitted(writeRow.id, updated);
    // best-effort mirror refresh + generic audit_log row
    return NextResponse.json({ writeId: writeRow.id, status: 'committed', asset: updated });
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    await markWriteFailed(writeRow.id, message);
    return NextResponse.json(
      { writeId: writeRow.id, status: 'failed', error: 'IT Glue write failed', message },
      { status: 502 }
    );
  }
}

For Route 53's PATCH (record update): use requireAdmin() (D-04, not requirePermission) — read current recordset from the mirror as before_value, INSERT route53_audit_log (status='pending', before_value, after_value=requested), call ChangeResourceRecordSetsCommand with Action: 'UPSERT', then on success markCommitted + INSERT route53_record_history (source='pulse_crud') + refresh mirror row; on failure markFailed with error.message (D-07) and do NOT write record_history (nothing changed on AWS's side — RESEARCH.md Pattern 3).

Pre-write guardrail pattern to reuse for D-01's write-type allowlist (lines 86-94, credential-field blocklist — same shape, different check):

const lowerField = fieldName.toLowerCase();
if (/(password|secret|key|token|credential)/.test(lowerField)) {
  return NextResponse.json({ error: 'Refusing to write to credential-shaped field' }, { status: 400 });
}

Route 53 equivalent: reject Type: 'NS' or Type: 'SOA' with 400 before constructing ChangeResourceRecordSetsCommand — same "belt over the prompt/UI's braces" defensive-check pattern, enforced server-side even though the UI shouldn't offer these types.

Failure-response pattern (lines 188-200):

} catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  await markWriteFailed(writeRow.id, message);
  return NextResponse.json(
    { writeId: writeRow.id, status: 'failed', error: 'IT Glue write failed', message },
    { status: 502 }
  );
}

Use 502 for AWS-side failures (external system rejected/errored), matching this exact convention (not the CLAUDE.md-listed 500/503 — 502 is itglue's precedent for "upstream integration failed").


lib/services/route53-write-persistence.ts (helper — pending/committed/failed lifecycle)

Analog: lib/services/analyzer/asset-audit/persistence.ts

Pending-write insert pattern (lines 321-358):

export async function createPendingWrite(input: {
  audit_id: string | null;
  asset_type: 'flexible_asset' | 'configuration';
  asset_id: number | string;
  field_name: string;
  before_value: unknown;
  after_value: unknown;
  performed_by_user_id: string | null;
  source_evidence: unknown;
}): Promise<{ id: string }> {
  const res = await postgresClient.query<{ id: string }>(
    `INSERT INTO itglue_writes
       (audit_id, asset_type, asset_id, field_name, before_value, after_value,
        performed_by_user_id, status, source_evidence)
     VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, 'pending', $8::jsonb)
     RETURNING id::text AS id`,
    [/* ... */]
  );
  return res.rows[0];
}

Commit/fail transition pattern (lines 359-379):

export async function markWriteCommitted(id: string, itglueResponse: unknown): Promise<void> {
  await postgresClient.query(
    `UPDATE itglue_writes SET status = 'committed', itglue_response = $2::jsonb WHERE id = $1`,
    [id, JSON.stringify(itglueResponse ?? null)]
  );
}

export async function markWriteFailed(id: string, errorMessage: string): Promise<void> {
  await postgresClient.query(
    `UPDATE itglue_writes SET status = 'failed', error_message = $2 WHERE id = $1`,
    [id, errorMessage]
  );
}

Route 53's createPendingAuditLog() / markAuditCommitted() / markAuditFailed() should follow this exact three-function shape against route53_audit_log.


lib/services/sync-scheduler.ts (modified — extend sync_type union + defaultSchedules + dispatch)

sync_type union (line 25) — append 'route53-incremental' | 'route53-full' to the existing pipe-delimited string union.

defaultSchedules entry pattern (lines 198-221, veeam-incremental/veeam-full/veeam-rpo-check):

{
  id: 'veeam-incremental',
  name: 'Veeam Incremental Sync',
  description: 'Syncs Veeam backup data every 30 minutes',
  cron_expression: '*/30 * * * *',
  sync_type: 'veeam-incremental',
  is_enabled: false,
},
{
  id: 'veeam-full',
  name: 'Veeam Full Sync',
  description: 'Full Veeam backup data sync daily at 2:00 AM',
  cron_expression: '0 2 * * *',
  sync_type: 'veeam-full',
  is_enabled: false,
},

Route 53 per D-11 (incremental + daily full): add route53-incremental (e.g. */15 * * * *) and route53-full (e.g. 0 3 * * *, avoiding collision with weekly-full's 0 3 * * 0 and veeam-full's 0 2 * * *), both is_enabled: false by default (matches every new-integration default in this file).

Dispatch branch pattern — PAX8's config+disable-gated branch (lines 478-493, the one exception pattern, per D-10 explicitly NOT to copy the blocking behavior):

} else if (config.sync_type === 'pax8-daily') {
  const { isPax8Configured } = await import('@/lib/services/pax8-factory');
  if (!isPax8Configured()) {
    console.log('[SCHEDULER] Skipping pax8-daily — PAX8 not configured');
  } else {
    const disabledRes = await postgresClient.query<{ disabled: boolean }>(
      `SELECT disabled FROM integration_settings WHERE key = 'pax8'`
    );
    const isDisabled = disabledRes.rows[0]?.disabled === true;
    if (isDisabled) {
      console.log('[SCHEDULER] Skipping pax8-daily — PAX8 disabled via /admin/integrations');
    } else {
      const { getPax8SyncService } = await import('@/lib/services/pax8-sync-service');
      await getPax8SyncService().fullSync('scheduled');
    }
  }
}

Route 53's branch must OMIT the integration_settings.disabled gate entirely (D-10 — disable is display-only, scheduler keeps running):

} else if (config.sync_type === 'route53-incremental') {
  const { isRoute53Configured } = await import('@/lib/services/route53-factory');
  if (!isRoute53Configured()) {
    console.log('[SCHEDULER] Skipping route53-incremental — Route 53 not configured');
  } else {
    const { getRoute53SyncService } = await import('@/lib/services/route53-sync-service');
    await getRoute53SyncService().incrementalSync('scheduled');
  }
} else if (config.sync_type === 'route53-full') {
  const { isRoute53Configured } = await import('@/lib/services/route53-factory');
  if (!isRoute53Configured()) {
    console.log('[SCHEDULER] Skipping route53-full — Route 53 not configured');
  } else {
    const { getRoute53SyncService } = await import('@/lib/services/route53-sync-service');
    await getRoute53SyncService().fullSync('scheduled');
  }
}

Config-only check pattern for isXConfigured() mirrors the mimecast-sync branch (lines 494-504) equally well — both use dynamic import() to avoid eager-loading the sync service module at scheduler-import time.


lib/services/integration-health.ts (modified — add checkRoute53())

Live-check pattern with custom body (analog: checkDattoRmm(), lines 145-191) vs. generic liveCheck() helper pattern (analog: checkItglue(), lines 193-209):

async function checkItglue(): Promise<IntegrationHealth> {
  const apiKey = process.env.ITGLUE_API_KEY;
  const checkedAt = new Date().toISOString();
  if (!apiKey) {
    return { key: 'itglue', name: 'IT Glue', category: 'docs', status: 'not_configured', configured: false, checkedAt };
  }
  const live = await liveCheck({
    url: 'https://api.itglue.com/organizations?page[size]=1',
    headers: { 'x-api-key': apiKey },
  });
  return { key: 'itglue', name: 'IT Glue', category: 'docs', status: live.status, configured: true, latencyMs: live.latencyMs, error: live.error, checkedAt };
}

Route 53 needs a custom check function (not the generic liveCheck() fetch helper, since it's an AWS-SDK-signed call, not a plain HTTP fetch) that composes: (1) an isRoute53Configured() gate identical to checkDattoRmm()'s if (!url || !key || !secret) early return; (2) an auth probe via ListHostedZonesCommand({ MaxItems: '1' }); (3) D-12's NS-delegation check across all zones, using the dns.Resolver() snippet from RESEARCH.md's Code Examples section (dedicated resolver instance, setServers(['1.1.1.1','8.8.8.8']), never the global dns.setServers()). A mismatch on any zone should degrade the overall status (e.g. to a new/reused non-ok status) — extend IntegrationHealth's shape with an optional field (e.g. nsDelegationMismatches?: string[]) rather than overloading error.

checkConfigOnly() fallback pattern (lines 238-252) — NOT sufficient for Route 53 given D-12's mandatory live check, but useful during early-phase scaffolding before the full check is built:

function checkConfigOnly(key: string, name: string, category: IntegrationHealth['category'], envVars: string[]): IntegrationHealth {
  const checkedAt = new Date().toISOString();
  const allSet = envVars.every((v) => !!process.env[v]);
  return { key, name, category, status: allSet ? 'unknown' : 'not_configured', configured: allSet, checkedAt };
}

Registration point (lines 325-352, inside Promise.all([...]) in checkIntegrationHealth()): add checkRoute53(), alongside checkAutotask(), checkDattoRmm(), etc. (not wrapped in Promise.resolve() since it's already async, matching checkAutotask()/checkDattoRmm()'s bare-call style, not the checkConfigOnly() wrapped style).

IntegrationHealth.category union (line 36) — add 'network' category reuse (already exists, used by Auvik/Zabbix) or a new value if DNS warrants its own bucket; RESEARCH.md doesn't mandate a new category, 'network' fits.


app/admin/sync/page.tsx (modified — extend INTEGRATIONS tile array)

Tile shape (lines 9-16, interface; line 29 PAX8 entry):

interface IntegrationCard {
  id: string;
  category: string;
  product: string;
  description: string;
  href: string;
  logo: string;
  color: string;
}
// ...
{ id: 'pax8', category: 'Licensing', product: 'PAX8', description: 'Companies, subscriptions, products, and license billing', href: '/admin/sync/pax8', logo: '/logos/pax8.ico', color: 'blue' },

Add: { 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.ico', color: 'orange' } (confirm a /logos/route53.ico asset exists or is added; color should not collide with an adjacent tile — orange is already used by datto-rmm, consider yellow or check COLOR_MAP for available values before finalizing).


app/admin/sync/route53/page.tsx (component, request-response)

Analog: app/admin/sync/veeam/page.tsx (647 lines — multi-tab detail page)

Imports pattern (lines 3-22):

import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusBadge } from '@/components/ui/status-badge';
import SyncScheduler from '@/components/admin/SyncScheduler';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';

Data-fetch tab pattern (lines 197-243, sync-history tab + agents tab):

const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
  fetch('/api/sync/history?entityType=veeam&limit=50')
    .then(r => r.json())
    .then(d => setRows(d.rows ?? d))
    .finally(() => setLoading(false));
}, []);

Route 53's zones/records/history tabs follow this exact useState/useEffect/fetch() shape — no SWR, per CLAUDE.md. Use components/admin/DataTable.tsx for the zones/records list (RESEARCH.md's Reusable Assets) and components/admin/DetailModal.tsx for record before/after drill-down (formatted/raw tabs already established there).

Manual-sync-trigger pattern (lines 567-593):

const [syncing, setSyncing] = useState(false);
const handleSync = async () => {
  setSyncing(true);
  try {
    await fetch('/api/veeam/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
    // poll for completion
    const r = await fetch('/api/veeam/sync');
    // ...
  } finally {
    setSyncing(false);
  }
};

Route 53's "Sync Now" button follows this shape against /api/route53/sync.


Shared Patterns

Auth gating — reads vs. writes

Source: lib/auth-utils.ts lines 31-45 (requireAuth), 51-78 (requirePermission), 79-103 (requireAdmin)

export async function requireAuth() {
  const session = await getSession();
  if (!session) {
    return { session: null, error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
  }
  return { session, error: null };
}

export async function requireAdmin() {
  const { session, error } = await requireAuth();
  if (error) return { session: null, error };
  const userRole = (session!.user as UserWithRole).role || "user";
  if (userRole !== "admin" && userRole !== "super-admin") {
    return { session: null, error: NextResponse.json({ error: "Forbidden - Admin access required" }, { status: 403 }) };
  }
  return { session, error: null };
}

Apply to: All app/api/route53/* write routes (requireAdmin(), per D-04) and read routes (requireAuth() at minimum, matching itglue/veeam's read-route convention per RESEARCH.md's ASVS V4 note).

Failed-write audit logging (D-07)

Source: app/api/analyzer/itglue/applications/[id]/apply/route.ts lines 188-200 (catch block) + lib/services/analyzer/asset-audit/persistence.ts lines 372-379 (markWriteFailed)

} catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  await markWriteFailed(writeRow.id, message);
  return NextResponse.json({ writeId: writeRow.id, status: 'failed', error: '... write failed', message }, { status: 502 });
}

Apply to: Every app/api/route53/zones/[zoneId]/records/* POST/PATCH/DELETE route — the pending-row-before-external-call discipline is non-negotiable per RESEARCH.md ("never write to the external system without an audit row already in flight").

Dedicated-schema-per-integration migrations

Source: migrations/091_pax8_tables.sql (whole file) — raw_payload JSONB, synced_at/is_deleted/deleted_at audit columns, idx_<table>_is_deleted index per table, soft (non-FK) refs where sync insert order isn't guaranteed, hard FK only for true header/line relationships. Apply to: route53_zones / route53_records (mirror tables) — route53_record_history / route53_audit_log (ledger tables) should instead follow migrations/075_itglue_audit.sql's itglue_writes shape (status CHECK constraint, performed_by_user_id FK to "user"(id) ON DELETE SET NULL, performed_at/generated_at timestamps).

integration_settings display-only disable toggle (D-10)

Source: migrations/081_integration_settings.sql (seed pattern), lib/services/integration-health.ts applyDisableOverlay() (lines 310-319)

INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING;
async function applyDisableOverlay(items: IntegrationHealth[]): Promise<IntegrationHealth[]> {
  const [envSet, dbSet] = [getEnvDisabledKeys(), await getDbDisabledKeys()];
  if (envSet.size === 0 && dbSet.size === 0) return items;
  const disabled = new Set([...envSet, ...dbSet]);
  return items.map((item) => disabled.has(item.key) ? { ...item, status: 'disabled', error: null, configured: false } : item);
}

Apply to: checkRoute53()'s result flows through this existing overlay automatically once key: 'route53' is used — no new overlay code needed. Do not add a Route 53 branch to the sync-scheduler's dispatch that checks integration_settings.disabled (that's the PAX8-only exception, explicitly not to be replicated per D-10).

Live DNS resolution via a dedicated dns.Resolver() instance (D-12)

Source: lib/services/pipeline-steps/ping-flap-suppress.ts line 6 (import { promises as dns } from 'dns'; — existing codebase precedent for using Node's dns module) + RESEARCH.md's Code Examples section (full checkNsDelegation() implementation using new Resolver() + setServers() + promisify())

import { Resolver } from 'dns';
import { promisify } from 'util';

const resolver = new Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']); // dedicated instance — does not affect global dns resolution
const resolveNs = promisify(resolver.resolveNs.bind(resolver));

Apply to: checkRoute53() in lib/services/integration-health.ts only. Never call the global dns.setServers() — that would affect all DNS resolution in the process, including internal service hostnames (RESEARCH.md Pitfall 5).

No Analog Found

None. Every file in this phase has at least a role-match analog; the CRUD write-back pattern (the phase's highest-risk area) has an exact analog in the IT Glue write pipeline.

Metadata

Analog search scope: lib/services/*.ts (veeam-, datto-rmm-, pax8-*), lib/services/analyzer/asset-audit/, lib/services/pipeline-steps/, app/api/pax8/, app/api/analyzer/itglue/, app/admin/sync/, migrations/075_*, migrations/081_*, migrations/091_*, lib/auth-utils.ts Files scanned: 15 read in full or targeted excerpt Pattern extraction date: 2026-08-05