feat(24-05): read routes for zones, records, history, and sync status

- 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
This commit is contained in:
lorentz 2026-08-05 20:33:58 -04:00
parent f4e151dedd
commit 53ec51c5e0
4 changed files with 300 additions and 0 deletions

View file

@ -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 }
);
}
}

View file

@ -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<string, unknown> | null;
after_value: Record<string, unknown> | 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<HistoryRow>(
`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 }
);
}
}

View file

@ -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<string, unknown> | 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<RecordRow>(
`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 }
);
}
}

View file

@ -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<ZoneRow>(
`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 }
);
}
}