docs(24): add pattern map
This commit is contained in:
parent
998c4b1a36
commit
0eab4996e9
1 changed files with 736 additions and 0 deletions
|
|
@ -0,0 +1,736 @@
|
||||||
|
# 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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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()`):
|
||||||
|
```typescript
|
||||||
|
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`):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
} 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):
|
||||||
|
```sql
|
||||||
|
-- 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`:
|
||||||
|
```sql
|
||||||
|
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):
|
||||||
|
```sql
|
||||||
|
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:
|
||||||
|
```sql
|
||||||
|
-- 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:
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
} 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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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`):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
} 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):
|
||||||
|
```typescript
|
||||||
|
} 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):
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
```typescript
|
||||||
|
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`)
|
||||||
|
```typescript
|
||||||
|
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`)
|
||||||
|
```typescript
|
||||||
|
} 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)
|
||||||
|
```sql
|
||||||
|
INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING;
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
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()`)
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue