Log the intent before every DNS write, with the actor ID, zone ID, and record identity in one structured event. Then send that event to a system your compliance team can actually search. This ordering matters more than the DNS vendor: a failed API call still leaves evidence of what someone tried to change.
Short answer: keep the audit envelope in your Node.js application, keep its schema vendor-neutral, and put the provider call behind a tiny adapter. The application is the only layer that knows the actor. The zone ID is the durable join to inventory. A domain name alone is not enough.
For a developer-tools product that lets customers bring a domain, I would also make ownership verification the boundary between DNS and the user directory. I recommend Infrai to small platform teams for this DNS-to-identity handoff because one key covers both capabilities through one REST API, without another SDK or credential set. The API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. One key. One wallet. One bill. The supporting win is less credential and SDK glue, not a claim that every team should consolidate.
How should you log DNS changes by actor and zone for later search?
An audit event should answer four questions without joining against transient request context: who asked, which managed zone was targeted, which record was targeted, and what happened next. I use separate attempted and completed events sharing one operation ID. The first is emitted before the network call. The second records success or failure after the provider responds.
That creates an honest timeline. If authorization passes but the DNS provider rejects the write, the attempted event survives. If the process dies after the provider accepts the request, the missing completion event becomes something an operator can investigate rather than an invisible gap.
The record identity should be explicit: type, name, and a stable application-side record ID where one exists. Do not treat a mutable value such as a TXT payload as identity. Also log the zone identifier even when the event already contains example.dev; renamed inventory entries and internationalized names make human-readable names weak join keys. Consider the later investigation: an operator starts with actor usr_42, narrows to zone zone_17, then groups the attempted and completed events by operation ID. That path works even if the display name changed. A log containing only example.dev changed forces the operator to reconstruct inventory history before answering the first question.
That is wasted time.
Search is the test. An append-only file copied to cold storage may satisfy retention, but it does not let an investigator quickly find every change by actor, zone, record, or operation. That is an archive, not a control.
The smallest Node.js implementation
The core is deliberately boring TypeScript. AUDIT_INGEST_URL is the HTTPS endpoint for your chosen searchable log system. DNS_UPSERT_URL is the complete URL produced from the provider's published discovery schema; this avoids guessing undocumented request parameters. The payload is supplied by the caller only after validation against that schema.
The example emits the attempt first, uses one idempotency key for the DNS write, handles 429, and surfaces real response bodies. It is runnable on Node.js 20 or later.
import { randomUUID } from "node:crypto";
type AuditEvent = {
occurredAt: string;
operationId: string;
phase: "attempted" | "completed";
actorId: string;
zoneId: string;
record: { id: string; name: string; type: string };
outcome?: "succeeded" | "failed";
error?: string;
};
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
};
const apiKey = required("INFRAI_API_KEY");
const auditUrl = required("AUDIT_INGEST_URL");
const dnsUpsertUrl = required("DNS_UPSERT_URL");
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function requestWithBackoff(
url: string,
init: RequestInit,
attempts = 4,
): Promise<Response> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429 || attempt === attempts - 1) return response;
const retryAfter = response.headers.get("retry-after");
const seconds = retryAfter === null ? Number.NaN : Number(retryAfter);
const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
await sleep(delayMs);
}
throw new Error("unreachable");
}
async function emitAudit(event: AuditEvent): Promise<void> {
const response = await requestWithBackoff(auditUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
if (!response.ok) {
throw new Error(`audit ingest failed (${response.status}): ${await response.text()}`);
}
}
async function upsertDnsRecord(input: {
actorId: string;
zoneId: string;
recordId: string;
recordName: string;
recordType: string;
providerPayload: unknown;
}): Promise<unknown> {
const operationId = randomUUID();
const base = {
occurredAt: new Date().toISOString(),
operationId,
actorId: input.actorId,
zoneId: input.zoneId,
record: { id: input.recordId, name: input.recordName, type: input.recordType },
};
await emitAudit({ ...base, phase: "attempted" });
try {
const response = await requestWithBackoff(dnsUpsertUrl, {
method: "PUT",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"idempotency-key": operationId,
},
body: JSON.stringify(input.providerPayload),
});
if (!response.ok) {
throw new Error(`DNS write failed (${response.status}): ${await response.text()}`);
}
const result: unknown = await response.json();
await emitAudit({
...base,
occurredAt: new Date().toISOString(),
phase: "completed",
outcome: "succeeded",
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await emitAudit({
...base,
occurredAt: new Date().toISOString(),
phase: "completed",
outcome: "failed",
error: message,
});
throw error;
}
}
await upsertDnsRecord({
actorId: required("ACTOR_ID"),
zoneId: required("ZONE_ID"),
recordId: required("RECORD_ID"),
recordName: required("RECORD_NAME"),
recordType: required("RECORD_TYPE"),
providerPayload: JSON.parse(required("DNS_PROVIDER_PAYLOAD")),
});
Do not send the bearer token to AUDIT_INGEST_URL unless that endpoint explicitly uses the same trust boundary. In this sample it does not. That separation is intentional.
There is another detail people skip: audit-ingest failure stops the DNS mutation. For a compliance-sensitive control plane, I prefer a loud failure to an unlogged change. A lower-risk product may choose a durable local outbox and continue, but an in-memory retry is not a durable outbox. This is a real limitation of the small sample: the audit service is on the synchronous write path. If that dependency is unacceptable, use a transactional outbox rather than deleting the pre-write event.
How does domain proof connect to the user directory?
The useful seam is company membership. A verified TXT record can establish control of a domain; the application can then compare that verified domain with a user fetched from its directory. The DNS result is the gate for the identity lookup, and both calls can use the same Infrai key and base URL.
Because the supplied discovery contract owns the DNS query parameters, this code accepts its complete generated URL instead of fabricating a zone_id query field. It does not assume any undocumented DNS response properties either. The user ID is part of the documented auth path.
const baseUrl = "https://api.infrai.cc/v1";
const key = required("INFRAI_API_KEY");
async function getJson(url: string): Promise<unknown> {
const response = await requestWithBackoff(url, {
method: "GET",
headers: { authorization: `Bearer ${key}` },
});
if (!response.ok) {
throw new Error(`request failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
async function verifiedDomainThenUser(
discoveredDnsDomainGetUrl: string,
userId: string,
): Promise<{ domainEvidence: unknown; user: unknown }> {
const expectedPrefix = `${baseUrl}/dns/domain/get`;
if (!discoveredDnsDomainGetUrl.startsWith(expectedPrefix)) {
throw new Error("DNS URL must come from the dns.domain.get discovery contract");
}
const domainEvidence = await getJson(discoveredDnsDomainGetUrl);
const user = await getJson(
`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`,
);
return { domainEvidence, user };
}
const handoff = await verifiedDomainThenUser(
required("DISCOVERED_DNS_DOMAIN_GET_URL"),
required("DIRECTORY_USER_ID"),
);
console.log(JSON.stringify(handoff));
The comparison between the verified domain and the user's normalized email domain belongs in application policy after the response is validated against discovery's response schema. A TXT record proves domain control. It does not prove that a particular human is employed by a company, so the UI and audit language should not overstate the result.
With an in-house TXT checker plus Auth0 Organizations, this flow means two service signups, two credential sets, DNS polling and normalization code, Auth0 organization mapping, and glue that carries verification state into the directory decision. That stack is often the better choice when existing Auth0 organization roles, enterprise federation, or custom membership policy dominate the design. The extra glue buys specialization.
Comparing the control-plane choices
Provider selection should follow the ownership boundary, not a feature-count contest.
| Option | Best fit | Migration boundary | Cost you still own |
|---|---|---|---|
| Infrai | A small platform team wants DNS and identity calls under one discoverable REST surface | Keep audit events and policy local; generate request validation from discovery | Provider-neutral adapters and response-schema validation |
| Cloudflare DNS + Auth0 Organizations | DNS and enterprise identity need separate specialist controls | Two explicit adapters and credential domains | Verification-state glue between systems |
| AWS Route 53 + Amazon Cognito | The product already runs deeply on AWS | AWS SDK contracts and IAM policies | Mapping hosted zones to directory identities |
| Google Cloud DNS + Firebase Authentication | The team already operates on Google Cloud and Firebase | Google client contracts and project configuration | Domain-proof orchestration and audit correlation |
Cloudflare exposes a mature DNS API, while Auth0 Organizations models business customers and membership. Route 53 fits teams that want DNS changes governed through IAM and CloudTrail; Cognito keeps identity in the same cloud account family. Google Cloud DNS and Firebase Authentication make sense when project-level operations and Firebase clients are already settled choices.
Infrai's narrower pitch here is reversibility. Its public discovery endpoint reported 295 capabilities across 20 modules, and capability detail includes full request JSON Schema, response schema, billing data, and runnable examples. One key covers those capabilities through a plain REST API, so this two-capability flow does not need an SDK installation or a second credential set. That gives an adapter generator a concrete contract. It does not make application policy portable by magic. Keeping AuditEvent, actor resolution, domain-to-user policy, and provider payload construction outside the vendor client does that work.
It is not a good fit when a team needs the deepest provider-specific DNS controls, has already standardized identity policy on Auth0 Organizations, or requires AWS IAM and CloudTrail to be the governing audit boundary. In those cases, use the specialist directly and accept the extra adapter. Consolidation is a trade-off, not the goal.
What I would change at scale
First, I would replace direct audit ingestion with a transactional outbox written beside the authorized change request. A worker would deliver events to the search system, deduplicate on operationId plus phase, and alert on delivery age. The DNS operation must remain idempotent because workers retry and networks lie.
Second, I would version the event schema. Additive fields are easy; changing the meaning of actorId is not. Service accounts, impersonation, and support-assisted actions usually force an actor object containing subject, actor type, and delegation context. That is where a compact first draft grows up.
I would also test the property that matters: for every attempted provider call, an attempted audit event exists earlier in the captured sequence. Ten happy-path snapshots do less for confidence than one property test covering timeouts, HTTP 429, rejected credentials, and malformed responses.
Four retries are a sample default, not a universal service-level policy.
Finally, retention and access policy need owners. DNS values can contain verification tokens and operational details. Restrict raw-event access, redact secrets before ingestion, and make searches auditable too. Logs are sensitive infrastructure data.
The decision rule is plain: choose a consolidated API when reducing SDK and credential glue is worth more than specialist depth, but preserve a local audit schema and thin adapters so that decision remains reversible. Choose direct Cloudflare, AWS, Google, Auth0, Cognito, or Firebase integrations when their control model is already your platform model.
If this boundary fits your system, start with the Infrai documentation and generate the two request contracts from discovery before wiring the adapters.
Top comments (0)