/*
RFC-0753: Shared helpers for DNS record commands — declaration file loading,
zone ID resolution, environment variable resolution, and record identity.
- Do not implement command logic — that lives in the individual command files.
- RFC-0753: initial DNS helpers — loadDnsRecordFile, resolveDnsZoneId, resolveDnsEnv, recordIdentity.
- RFC-0753: recordsMatch — SVCB/HTTPS rdata parse-compare via svcb-normalize (AGD-05 subset params, HTTPS satisfies declared SVCB), replacing literal compare that flagged Cloudflare presentation normalization as drift.
- RFC-0753: pickBestLiveMatch — best-fit declared→live selection for duplicate type+name identities, extracted from dns.record.validate for unit testing.
*/
import { join } from "node:path";
import { dnsRecordFileSchema } from "@warpgogol/werkstatt-shared/ontology/schemas";
import type {
DnsRecordDeclaration,
DnsRecordFile,
} from "@warpgogol/werkstatt-shared/ontology/schemas";
import { isSvcbType, svcbContentsMatch } from "./svcb-normalize.ts";
import { filterEnv, sourceDotenv } from "../leitstand/adapters/cloudflare-workers.ts";
import { resolveZoneId } from "../subdomain/subdomain-helpers.ts";
import { resolveCacheClonePath } from "../sternsystem/registry-io.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export { resolveZoneId as resolveDnsZoneId };
export function flagString(
input: { flags: Record },
key: string,
): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
export function flagBoolean(
input: { flags: Record },
key: string,
): boolean | undefined {
const v = input.flags[key];
return typeof v === "boolean" ? v : undefined;
}
export async function loadDnsRecordFile(
workspaceRoot: string,
systemId: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const cacheDir = resolveCacheClonePath(workspaceRoot, systemId);
const filePath = join(cacheDir, "dns-records.yaml");
let raw: string;
try {
raw = await io.readFile(filePath);
} catch {
return null;
}
const { parse } = await import("yaml");
const parsed = parse(raw);
return dnsRecordFileSchema.parse(parsed) as DnsRecordFile;
}
export async function resolveDnsEnv(secretsFilePath?: string): Promise> {
const secretsEnv = secretsFilePath ? await sourceDotenv(secretsFilePath) : {};
return { ...filterEnv(process.env), ...secretsEnv };
}
export function resolveZoneDomainForSystem(
systems: Array<{ id: string; deployment?: { channels: { main: { url: string } } } }>,
systemId: string,
): string {
for (const system of systems) {
if (system.id !== systemId) continue;
if (!system.deployment) continue;
try {
return new URL(system.deployment.channels.main.url).hostname;
} catch {
continue;
}
}
throw new Error(`[dns] Could not resolve zone domain for system '${systemId}'.`);
}
export function recordIdentity(record: DnsRecordDeclaration): string {
return `${record.type}:${record.name}`;
}
export function recordsMatch(
declared: DnsRecordDeclaration,
live: {
type: string;
name: string;
content: string;
proxied: boolean;
priority: number | null;
ttl?: number | null;
},
): boolean {
if (live.type !== declared.type && !(declared.type === "SVCB" && live.type === "HTTPS")) {
return false;
}
if (live.name !== declared.name) return false;
// SVCB/HTTPS rdata is presentation-normalized by Cloudflare (trailing dot on
// the canonical target, quoted params) — parse-compare instead of literal
// compare, with declared ⊆ live param semantics per AGD-05.
if (isSvcbType(declared.type)) {
if (!svcbContentsMatch(declared.content, live.type, live.content)) return false;
} else {
const declaredContent =
declared.type === "TXT" ? normalizeTxtForCompare(declared.content) : declared.content;
const liveContent =
declared.type === "TXT" ? normalizeTxtForCompare(live.content) : live.content;
if (declaredContent !== liveContent) return false;
}
const declaredProxied = declared.proxied ?? false;
if (live.proxied !== declaredProxied) return false;
if (declared.priority !== undefined && declared.priority !== live.priority) {
return false;
}
if (declared.ttl !== undefined && live.ttl !== declared.ttl) {
return false;
}
return true;
}
/**
* Best-fit declared→live matching: multiple live records may share a type+name
* identity (parallel MX, dual TXT at apex). Pick the unconsumed live record that
* fully matches the declaration; fall back to the first candidate so the caller
* still has a live record for mismatch detail. A live HTTPS record is a
* candidate for a declared SVCB (wire alias).
*/
export function pickBestLiveMatch(
declared: DnsRecordDeclaration,
liveRecords: Array<{
id: string;
type: string;
name: string;
content: string;
proxied: boolean;
priority: number | null;
ttl?: number | null;
}>,
consumedLiveIds: ReadonlySet,
): (typeof liveRecords)[number] | undefined {
const candidates = liveRecords.filter(
(r) =>
!consumedLiveIds.has(r.id) &&
r.name === declared.name &&
(r.type === declared.type || (declared.type === "SVCB" && isSvcbType(r.type))),
);
return candidates.find((r) => recordsMatch(declared, r)) ?? candidates[0];
}
function normalizeTxtForCompare(content: string): string {
return content
.trim()
.replace(/^["']|["']$/g, "")
.replace(/\\([.;()])|(["'])/g, "$1")
.trim()
.replace(/\s+/g, " ");
}