chore: merge executor worktree (worktree-agent-a8fd5f9961335cf8d) — plan 24-05
This commit is contained in:
commit
c47de2a91c
9 changed files with 1298 additions and 0 deletions
89
app/api/route53/sync/route.ts
Normal file
89
app/api/route53/sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
263
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts
Normal file
263
app/api/route53/zones/[zoneId]/records/[recordId]/route.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* PATCH /api/route53/zones/:zoneId/records/:recordId — update a resource
|
||||
* record set (Route 53 UPSERT).
|
||||
* DELETE /api/route53/zones/:zoneId/records/:recordId — delete a resource
|
||||
* record set.
|
||||
*
|
||||
* `recordId` is the URL-encoded `record_key`
|
||||
* (`${zoneId}:${name}:${type}:${setIdentifier ?? ''}`).
|
||||
*
|
||||
* Both handlers follow the same eight-step sequence as POST in
|
||||
* ../route.ts:
|
||||
* 1. requireAdmin() (D-04) — never rely on the UI hiding a control (T-24-02)
|
||||
* 2. isRoute53Configured() -> 503
|
||||
* 3. parse params + body
|
||||
* 4. validateRecordWrite() (D-01, before any AWS command is constructed)
|
||||
* 5. loadMirrorRecord() for beforeValue; null -> 404. The loaded row's exact
|
||||
* name/type/ttl/resourceRecords are what get submitted to AWS — Route 53
|
||||
* rejects or mis-targets a DELETE whose recordset doesn't match exactly
|
||||
* (24-RESEARCH.md Pitfall 3). Never build a DELETE from client-supplied
|
||||
* { name, type } alone.
|
||||
* 6. createPendingAuditLog before any AWS call (D-07/SC-3)
|
||||
* 7. submitRecordChange + pollChangeStatus -> markAuditCommitted ->
|
||||
* insertPulseCrudHistory -> upsertMirrorRecord/softDeleteMirrorRecord -> 200
|
||||
* 8. catch -> sanitizeAwsError -> markAuditFailed -> 502 (no history row —
|
||||
* nothing changed on AWS's side, 24-RESEARCH.md Pattern 3)
|
||||
*
|
||||
* D-03 compliance: both mutations execute on the first request. No `confirm`
|
||||
* body flag, no two-phase endpoint, no staged-approval status column — the
|
||||
* audit trail is the control, not a pre-write block.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/auth-utils';
|
||||
import { isRoute53Configured } from '@/lib/services/route53-factory';
|
||||
import { validateRecordWrite, sanitizeAwsError } from '@/lib/services/route53-record-validation';
|
||||
import {
|
||||
createPendingAuditLog,
|
||||
markAuditCommitted,
|
||||
markAuditFailed,
|
||||
insertPulseCrudHistory,
|
||||
upsertMirrorRecord,
|
||||
softDeleteMirrorRecord,
|
||||
loadMirrorRecord,
|
||||
} from '@/lib/services/route53-write-persistence';
|
||||
import { submitRecordChange, pollChangeStatus } from '@/lib/services/route53-change-submit';
|
||||
|
||||
/**
|
||||
* Verify the decoded record key's zone prefix matches the `zoneId` path
|
||||
* param — prevents a caller from mutating a record in a different zone
|
||||
* through a mismatched path (T-24-17).
|
||||
*/
|
||||
function recordKeyMatchesZone(recordKey: string, zoneId: string): boolean {
|
||||
return recordKey.startsWith(`${zoneId}:`);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ zoneId: string; recordId: string }> }
|
||||
) {
|
||||
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 write Route 53 records',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const { zoneId, recordId } = await params;
|
||||
const recordKey = decodeURIComponent(recordId);
|
||||
if (!recordKeyMatchesZone(recordKey, zoneId)) {
|
||||
return NextResponse.json({ error: 'Record does not belong to this zone' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const validated = validateRecordWrite(body);
|
||||
if (!validated.ok) {
|
||||
return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 });
|
||||
}
|
||||
const { name, type, ttl, resourceRecords } = validated.value;
|
||||
const setIdentifier: string | null =
|
||||
typeof body.setIdentifier === 'string' && body.setIdentifier.trim().length > 0
|
||||
? body.setIdentifier.trim()
|
||||
: null;
|
||||
|
||||
const existing = await loadMirrorRecord(recordKey);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Record not found' }, { status: 404 });
|
||||
}
|
||||
const beforeValue = {
|
||||
name: existing.name,
|
||||
type: existing.type,
|
||||
ttl: existing.ttl,
|
||||
setIdentifier: existing.setIdentifier,
|
||||
resourceRecords: existing.resourceRecords,
|
||||
};
|
||||
const afterValue = { name, type, ttl, setIdentifier, resourceRecords };
|
||||
|
||||
const audit = await createPendingAuditLog({
|
||||
operation: 'update',
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
beforeValue,
|
||||
afterValue,
|
||||
performedByUserId: session!.user.id,
|
||||
performedByEmail: session!.user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
const { changeId, awsResponse } = await submitRecordChange({
|
||||
zoneId,
|
||||
action: 'UPSERT',
|
||||
recordSet: { name, type, ttl, resourceRecords, setIdentifier },
|
||||
});
|
||||
const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING';
|
||||
|
||||
await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse);
|
||||
await insertPulseCrudHistory({
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
changeAction: 'update',
|
||||
beforeValue,
|
||||
afterValue,
|
||||
changedByUserId: session!.user.id,
|
||||
changedByEmail: session!.user.email,
|
||||
auditLogId: audit.id,
|
||||
});
|
||||
await upsertMirrorRecord({
|
||||
recordKey,
|
||||
zoneId,
|
||||
name,
|
||||
type,
|
||||
setIdentifier,
|
||||
ttl,
|
||||
resourceRecords,
|
||||
aliasTarget: null,
|
||||
rawPayload: afterValue,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
auditId: audit.id,
|
||||
status: 'committed',
|
||||
propagationStatus,
|
||||
record: afterValue,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = sanitizeAwsError(err);
|
||||
console.error('[ROUTE53-WRITE] update failed:', message);
|
||||
await markAuditFailed(audit.id, err);
|
||||
return NextResponse.json(
|
||||
{ auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ zoneId: string; recordId: string }> }
|
||||
) {
|
||||
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 write Route 53 records',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const { zoneId, recordId } = await params;
|
||||
const recordKey = decodeURIComponent(recordId);
|
||||
if (!recordKeyMatchesZone(recordKey, zoneId)) {
|
||||
return NextResponse.json({ error: 'Record does not belong to this zone' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await loadMirrorRecord(recordKey);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Record not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// D-01 defence in depth: validate the type of the record being deleted the
|
||||
// same way a create/update is validated — an NS/SOA delete is as
|
||||
// destructive as an NS/SOA write.
|
||||
const validated = validateRecordWrite({
|
||||
name: existing.name,
|
||||
type: existing.type,
|
||||
ttl: existing.ttl ?? undefined,
|
||||
resourceRecords: existing.resourceRecords ?? [],
|
||||
});
|
||||
if (!validated.ok) {
|
||||
return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 });
|
||||
}
|
||||
|
||||
// The exact current recordset read from the mirror — Route 53 rejects or
|
||||
// mis-targets a DELETE that does not match exactly (24-RESEARCH.md
|
||||
// Pitfall 3). Never build this from client-supplied values.
|
||||
const { name, type, ttl, resourceRecords } = validated.value;
|
||||
const setIdentifier = existing.setIdentifier;
|
||||
const beforeValue = { name, type, ttl, setIdentifier, resourceRecords };
|
||||
|
||||
const audit = await createPendingAuditLog({
|
||||
operation: 'delete',
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
beforeValue,
|
||||
afterValue: null,
|
||||
performedByUserId: session!.user.id,
|
||||
performedByEmail: session!.user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
const { changeId, awsResponse } = await submitRecordChange({
|
||||
zoneId,
|
||||
action: 'DELETE',
|
||||
recordSet: { name, type, ttl, resourceRecords, setIdentifier: existing.setIdentifier },
|
||||
});
|
||||
const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING';
|
||||
|
||||
await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse);
|
||||
await insertPulseCrudHistory({
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
changeAction: 'delete',
|
||||
beforeValue,
|
||||
afterValue: null,
|
||||
changedByUserId: session!.user.id,
|
||||
changedByEmail: session!.user.email,
|
||||
auditLogId: audit.id,
|
||||
});
|
||||
await softDeleteMirrorRecord(recordKey);
|
||||
|
||||
return NextResponse.json({
|
||||
auditId: audit.id,
|
||||
status: 'committed',
|
||||
propagationStatus,
|
||||
record: null,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = sanitizeAwsError(err);
|
||||
console.error('[ROUTE53-WRITE] delete failed:', message);
|
||||
await markAuditFailed(audit.id, err);
|
||||
return NextResponse.json(
|
||||
{ auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
200
app/api/route53/zones/[zoneId]/records/route.ts
Normal file
200
app/api/route53/zones/[zoneId]/records/route.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* GET /api/route53/zones/:zoneId/records — list mirrored resource record
|
||||
* sets in a hosted zone. Optional `?type=` and `?search=` filters.
|
||||
* requireAuth() gated.
|
||||
*
|
||||
* POST /api/route53/zones/:zoneId/records — create a resource record set.
|
||||
* requireAdmin() gated (D-04). Sequence identical to PATCH/DELETE in
|
||||
* ./[recordId]/route.ts:
|
||||
* 1. requireAdmin() (D-04)
|
||||
* 2. isRoute53Configured() -> 503
|
||||
* 3. parse + validateRecordWrite() (D-01, before any AWS command)
|
||||
* 4. beforeValue = null; loadMirrorRecord must be null or 409
|
||||
* 5. createPendingAuditLog before any AWS call (D-07/SC-3)
|
||||
* 6. submitRecordChange + pollChangeStatus -> markAuditCommitted ->
|
||||
* insertPulseCrudHistory -> upsertMirrorRecord -> 201
|
||||
* 7. catch -> sanitizeAwsError -> markAuditFailed -> 502 (no history row)
|
||||
*/
|
||||
|
||||
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 { validateRecordWrite, sanitizeAwsError } from '@/lib/services/route53-record-validation';
|
||||
import {
|
||||
createPendingAuditLog,
|
||||
markAuditCommitted,
|
||||
markAuditFailed,
|
||||
insertPulseCrudHistory,
|
||||
upsertMirrorRecord,
|
||||
loadMirrorRecord,
|
||||
} from '@/lib/services/route53-write-persistence';
|
||||
import { submitRecordChange, pollChangeStatus } from '@/lib/services/route53-change-submit';
|
||||
import { buildRecordKey } from '@/lib/services/route53-record-key';
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ zoneId: string }> }
|
||||
) {
|
||||
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 write Route 53 records',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const { zoneId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
const validated = validateRecordWrite(body);
|
||||
if (!validated.ok) {
|
||||
return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 });
|
||||
}
|
||||
const { name, type, ttl, resourceRecords } = validated.value;
|
||||
const setIdentifier: string | null =
|
||||
typeof body.setIdentifier === 'string' && body.setIdentifier.trim().length > 0
|
||||
? body.setIdentifier.trim()
|
||||
: null;
|
||||
|
||||
const recordKey = buildRecordKey({ zoneId, name, type, setIdentifier });
|
||||
|
||||
const existing = await loadMirrorRecord(recordKey);
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Record already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
const afterValue = { name, type, ttl, setIdentifier, resourceRecords };
|
||||
const audit = await createPendingAuditLog({
|
||||
operation: 'create',
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
beforeValue: null,
|
||||
afterValue,
|
||||
performedByUserId: session!.user.id,
|
||||
performedByEmail: session!.user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
const { changeId, awsResponse } = await submitRecordChange({
|
||||
zoneId,
|
||||
action: 'CREATE',
|
||||
recordSet: { name, type, ttl, resourceRecords, setIdentifier },
|
||||
});
|
||||
const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING';
|
||||
|
||||
await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse);
|
||||
await insertPulseCrudHistory({
|
||||
zoneId,
|
||||
recordKey,
|
||||
recordName: name,
|
||||
recordType: type,
|
||||
changeAction: 'create',
|
||||
beforeValue: null,
|
||||
afterValue,
|
||||
changedByUserId: session!.user.id,
|
||||
changedByEmail: session!.user.email,
|
||||
auditLogId: audit.id,
|
||||
});
|
||||
await upsertMirrorRecord({
|
||||
recordKey,
|
||||
zoneId,
|
||||
name,
|
||||
type,
|
||||
setIdentifier,
|
||||
ttl,
|
||||
resourceRecords,
|
||||
aliasTarget: null,
|
||||
rawPayload: afterValue,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ auditId: audit.id, status: 'committed', propagationStatus, record: afterValue },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = sanitizeAwsError(err);
|
||||
console.error('[ROUTE53-WRITE] create failed:', message);
|
||||
await markAuditFailed(audit.id, err);
|
||||
return NextResponse.json(
|
||||
{ auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
app/api/route53/zones/route.ts
Normal file
52
app/api/route53/zones/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue