Files
vibn-frontend/lib/dns/cloud-dns.ts

145 lines
4.4 KiB
TypeScript

/**
* Google Cloud DNS driver.
*
* Auth uses the shared vibn-workspace-provisioner SA (which we granted
* roles/dns.admin). Zones are created as PUBLIC managed zones in the
* configured GCP project. Visibility is global (anycast) — see
* `AI_CAPABILITIES_ROADMAP.md#P5.1` for the residency note.
*
* Every read/write goes through https://dns.googleapis.com/dns/v1.
*/
import { getGcpAccessToken, GCP_PROJECT_ID } from '@/lib/gcp-auth';
import type { DnsProvider, DnsRecord, DnsZone } from './provider';
const API = `https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}`;
function zoneName(apex: string): string {
// Cloud DNS managed-zone names must match [a-z0-9-]+ and start with a letter.
const slug = apex.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return `vibn-${slug}`;
}
async function authedFetch(
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
path: string,
body?: unknown,
): Promise<Response> {
const token = await getGcpAccessToken();
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
};
if (body) headers['Content-Type'] = 'application/json';
return fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
async function parseOrThrow<T>(res: Response, context: string): Promise<T> {
const text = await res.text();
if (!res.ok) {
throw new Error(`[cloud-dns ${context} ${res.status}] ${text.slice(0, 500)}`);
}
return text ? JSON.parse(text) : ({} as T);
}
interface CloudDnsManagedZone {
name: string;
dnsName: string;
nameServers?: string[];
creationTime?: string;
visibility?: string;
}
interface CloudDnsRrSet {
name: string;
type: string;
ttl?: number;
rrdatas: string[];
}
interface CloudDnsChange {
additions?: CloudDnsRrSet[];
deletions?: CloudDnsRrSet[];
kind: 'dns#change';
}
export const cloudDnsProvider: DnsProvider = {
id: 'cloud_dns',
async createZone(apex) {
const name = zoneName(apex);
const dnsName = apex.endsWith('.') ? apex : `${apex}.`;
// Idempotent: if the zone already exists, return it unchanged.
const existing = await cloudDnsProvider.getZone(apex);
if (existing) return existing;
const res = await authedFetch('POST', `/managedZones`, {
name,
dnsName,
description: `Vibn-managed zone for ${apex}`,
visibility: 'public',
});
const zone = await parseOrThrow<CloudDnsManagedZone>(res, 'createZone');
return {
apex,
zoneId: zone.name,
nameservers: zone.nameServers ?? [],
createdAt: zone.creationTime,
};
},
async getZone(apex) {
const name = zoneName(apex);
const res = await authedFetch('GET', `/managedZones/${name}`);
if (res.status === 404) return null;
const zone = await parseOrThrow<CloudDnsManagedZone>(res, 'getZone');
return {
apex,
zoneId: zone.name,
nameservers: zone.nameServers ?? [],
createdAt: zone.creationTime,
};
},
/**
* Replaces the record set for each (name,type) pair in `records`. Other
* record sets on the zone are untouched.
*/
async setRecords(apex, records) {
const name = zoneName(apex);
const dnsName = apex.endsWith('.') ? apex : `${apex}.`;
// Fetch existing rrsets for these (name,type) pairs so we can delete
// them as part of the atomic change.
const existingRes = await authedFetch('GET', `/managedZones/${name}/rrsets`);
const existingJson = await parseOrThrow<{ rrsets?: CloudDnsRrSet[] }>(existingRes, 'listRrsets');
const existing = existingJson.rrsets ?? [];
const rel = (nm: string) => (nm === '@' ? dnsName : `${nm}.${dnsName}`);
const additions: CloudDnsRrSet[] = records.map(r => ({
name: rel(r.name),
type: r.type,
ttl: r.ttl ?? 300,
rrdatas: r.rrdatas,
}));
const wantPairs = new Set(additions.map(a => `${a.name}|${a.type}`));
const deletions = existing.filter(e => wantPairs.has(`${e.name}|${e.type}`));
const change: CloudDnsChange = { kind: 'dns#change', additions, deletions };
const res = await authedFetch('POST', `/managedZones/${name}/changes`, change);
await parseOrThrow(res, 'setRecords');
},
async deleteZone(apex) {
const name = zoneName(apex);
const res = await authedFetch('DELETE', `/managedZones/${name}`);
if (res.status === 404) return;
await parseOrThrow(res, 'deleteZone');
},
};