From 53ec51c5e0f78a3d444616ea00e774cd59ea26ed Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:33:58 -0400 Subject: [PATCH] feat(24-05): read routes for zones, records, history, and sync status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/api/route53/sync/route.ts: POST (requireAdmin, fire-and-forget) + GET (requireAuth, status/history) - app/api/route53/zones/route.ts: GET (requireAuth) list mirrored hosted zones - app/api/route53/zones/[zoneId]/records/route.ts: GET (requireAuth) list records with type/search filters - app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts: GET (requireAuth) append-only change ledger - None gated on integration_settings disable toggle (D-10 — route53 is not a PAX8-style exception) - /api/route53 confirmed absent from middleware.ts public-route list - tsc clean --- app/api/route53/sync/route.ts | 89 +++++++++++++++++++ .../records/[recordId]/history/route.ts | 79 ++++++++++++++++ .../route53/zones/[zoneId]/records/route.ts | 80 +++++++++++++++++ app/api/route53/zones/route.ts | 52 +++++++++++ 4 files changed, 300 insertions(+) create mode 100644 app/api/route53/sync/route.ts create mode 100644 app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts create mode 100644 app/api/route53/zones/[zoneId]/records/route.ts create mode 100644 app/api/route53/zones/route.ts diff --git a/app/api/route53/sync/route.ts b/app/api/route53/sync/route.ts new file mode 100644 index 0000000..adb29e2 --- /dev/null +++ b/app/api/route53/sync/route.ts @@ -0,0 +1,89 @@ +/** + * POST /api/route53/sync — trigger a manual full or incremental Route 53 sync. + * Body: { syncType?: 'full' | 'incremental' } (default 'full') + * requireAdmin() gated. Fire-and-forget — returns immediately, sync runs in + * the background. + * + * GET /api/route53/sync — sync status + recent history. + * requireAuth() gated. + * + * D-10: Route 53 is NOT gated by the admin-integrations disable toggle — + * that disable-blocks-sync behavior is a PAX8-only exception. Every other + * integration's toggle (including this one) is display-only. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { isRoute53Configured } from '@/lib/services/route53-factory'; +import { getRoute53SyncService } from '@/lib/services/route53-sync-service'; +import { sanitizeAwsError } from '@/lib/services/route53-record-validation'; + +export async function POST(req: NextRequest) { + const { session, error } = await requireAdmin(); + if (error) return error; + + if (!isRoute53Configured()) { + return NextResponse.json( + { + error: 'Route 53 not configured', + message: 'AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set to sync Route 53', + }, + { status: 503 } + ); + } + + const body = await req.json().catch(() => ({})); + const syncType = body.syncType === 'incremental' ? 'incremental' : 'full'; + + const svc = getRoute53SyncService(); + if (svc.isSyncInProgress()) { + return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 }); + } + + const triggeredBy = session!.user.email; + const runSync = syncType === 'incremental' ? svc.incrementalSync(triggeredBy) : svc.fullSync(triggeredBy); + runSync.catch((err) => + console.error('[ROUTE53-SYNC] Background sync error:', sanitizeAwsError(err)) + ); + + return NextResponse.json({ ok: true, message: 'Route 53 sync started' }); +} + +export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + + try { + const svc = getRoute53SyncService(); + const inProgress = svc.isSyncInProgress(); + + const counts = await postgresClient.query(` + SELECT + (SELECT COUNT(*) FROM route53_zones WHERE is_deleted = false) AS zones, + (SELECT COUNT(*) FROM route53_records WHERE is_deleted = false) AS records, + (SELECT COUNT(*) FROM route53_record_history) AS "historyRows" + `); + + 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 = 'route53' + ORDER BY started_at DESC + LIMIT 10` + ); + + return NextResponse.json({ + inProgress, + counts: counts.rows[0], + history: history.rows, + }); + } catch (err) { + console.error('[ROUTE53-SYNC] Failed to get sync status:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to get Route 53 sync status' }, + { status: 500 } + ); + } +} diff --git a/app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts b/app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts new file mode 100644 index 0000000..eea9c90 --- /dev/null +++ b/app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts @@ -0,0 +1,79 @@ +/** + * GET /api/route53/zones/:zoneId/records/:recordId/history + * Returns the append-only change ledger for a single record (SC-4's + * "history is queryable, not just current state" proof). `recordId` is the + * URL-encoded `record_key` (`${zoneId}:${name}:${type}:${setIdentifier}`). + * requireAuth() gated. + * + * Query params: `?limit=` (default 50, clamped to 1..200). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import type { Route53RecordHistory, Route53HistorySource } from '@/lib/types/route53'; + +interface HistoryRow { + id: string; + zone_id: string; + record_key: string; + record_name: string; + record_type: string; + change_action: 'create' | 'update' | 'delete'; + before_value: Record | null; + after_value: Record | null; + source: Route53HistorySource; + changed_by_user_id: string | null; + changed_by_email: string | null; + changed_at: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ zoneId: string; recordId: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + try { + const { recordId } = await params; + const recordKey = decodeURIComponent(recordId); + + const url = request.nextUrl; + const rawLimit = parseInt(url.searchParams.get('limit') ?? '50', 10); + const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 50, 1), 200); + + const res = await postgresClient.query( + `SELECT id, zone_id, record_key, record_name, record_type, change_action, + before_value, after_value, source, changed_by_user_id, changed_by_email, changed_at + FROM route53_record_history + WHERE record_key = $1 + ORDER BY changed_at DESC + LIMIT $2`, + [recordKey, limit] + ); + + const items: Route53RecordHistory[] = res.rows.map((row) => ({ + id: row.id, + zoneId: row.zone_id, + recordKey: row.record_key, + recordName: row.record_name, + recordType: row.record_type, + changeAction: row.change_action, + beforeValue: row.before_value, + afterValue: row.after_value, + source: row.source, + changedByUserId: row.changed_by_user_id, + changedByEmail: row.changed_by_email, + changedAt: row.changed_at, + })); + + return NextResponse.json({ items }); + } catch (err) { + console.error('[ROUTE53-HISTORY] Failed to fetch record history:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch Route 53 record history' }, + { status: 500 } + ); + } +} diff --git a/app/api/route53/zones/[zoneId]/records/route.ts b/app/api/route53/zones/[zoneId]/records/route.ts new file mode 100644 index 0000000..35f7a27 --- /dev/null +++ b/app/api/route53/zones/[zoneId]/records/route.ts @@ -0,0 +1,80 @@ +/** + * GET /api/route53/zones/:zoneId/records — list mirrored resource record + * sets in a hosted zone. Optional `?type=` and `?search=` filters. + * requireAuth() gated. + * + * POST (create) is added in a later plan task in this same file. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import type { Route53Record } from '@/lib/types/route53'; + +interface RecordRow { + record_key: string; + zone_id: string; + name: string; + type: string; + set_identifier: string | null; + ttl: number | null; + resource_records: Array<{ value: string }> | null; + alias_target: Record | null; + synced_at: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ zoneId: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + try { + const { zoneId } = await params; + const url = request.nextUrl; + const type = url.searchParams.get('type'); + const search = url.searchParams.get('search'); + + const conditions = ['zone_id = $1', 'is_deleted = false']; + const queryParams: unknown[] = [zoneId]; + + if (type) { + queryParams.push(type.toUpperCase()); + conditions.push(`type = $${queryParams.length}`); + } + if (search) { + queryParams.push(`%${search}%`); + conditions.push(`name ILIKE $${queryParams.length}`); + } + + const res = await postgresClient.query( + `SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target, synced_at + FROM route53_records + WHERE ${conditions.join(' AND ')} + ORDER BY name, type`, + queryParams + ); + + const records: Route53Record[] = res.rows.map((row) => ({ + 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, + syncedAt: row.synced_at, + isDeleted: false, + })); + + return NextResponse.json({ items: records }); + } catch (err) { + console.error('[ROUTE53-RECORDS] Failed to list records:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch Route 53 records' }, + { status: 500 } + ); + } +} diff --git a/app/api/route53/zones/route.ts b/app/api/route53/zones/route.ts new file mode 100644 index 0000000..d1fa13c --- /dev/null +++ b/app/api/route53/zones/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/route53/zones — list mirrored Route 53 hosted zones. + * requireAuth() gated. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import type { Route53Zone } from '@/lib/types/route53'; + +interface ZoneRow { + id: string; + name: string; + comment: string | null; + private_zone: boolean; + record_count: number; + authoritative_name_servers: string[] | null; + synced_at: string; +} + +export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + + try { + const res = await postgresClient.query( + `SELECT id, name, comment, private_zone, record_count, authoritative_name_servers, synced_at + FROM route53_zones + WHERE is_deleted = false + ORDER BY name` + ); + + const zones: Route53Zone[] = res.rows.map((row) => ({ + id: row.id, + name: row.name, + comment: row.comment, + privateZone: row.private_zone, + recordCount: row.record_count, + authoritativeNameServers: row.authoritative_name_servers, + syncedAt: row.synced_at, + isDeleted: false, + })); + + return NextResponse.json({ items: zones }); + } catch (err) { + console.error('[ROUTE53-ZONES] Failed to list zones:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch Route 53 zones' }, + { status: 500 } + ); + } +}