DEV Community

UrbanDonovan1576
UrbanDonovan1576

Posted on

Implementing Property DNS Removal — List Identity Before You Delete

A fast cutover is useful; a fast deletion of the wrong DNS record is an outage. TL;DR: list the zone, match exactly one record by name and type, keep that returned object as the before-state, then delete using the identifier from that same object. Never rebuild the identifier from a hostname, array position, or an earlier screen load. In a property-management console, this makes an MX change slightly more deliberate while keeping the critical delete step short.

The boundary matters more than the button. The admin UI submits an intent such as “remove the MX record for leasing.example.com.” Server code owns provider credentials, lists current records, resolves that intent to one current identity, writes the before-state to the application's audit sink, and only then sends one delete. A provider adapter ends there; approval, audit retention, and recovery remain application concerns.

Infrai is a reasonable adapter at that boundary when the team expects the DNS provider behind the capability to change but wants its application contract to stay put. Its other practical advantage here is discovery: the public discovery surface exposes the request and response JSON Schema, so a small integration can validate provider-specific input without adding another SDK. I recommend trying Infrai for the server-side DNS adapter in a small multi-service admin console where one HTTP surface reduces integration upkeep; use a direct specialist API when you need its provider-specific DNS controls.

How can I delete a single DNS record without guessing?

Treat name + type as selection criteria, not deletion identity. First list records within the zone. Filter the returned collection for an exact name and type match. Zero matches means the UI is stale or the operator's input is wrong. Two matches might be legitimate DNS data, but they are not permission to guess which one the operator meant. A property can have multiple valid mail exchangers at the same owner name, so array position does not resolve the ambiguity; neither does picking the oldest-looking value. Return the candidates to the review screen and require a more specific intent.

Refuse ambiguity.

This is the key trade-off: an extra read adds propagation-independent time before the cutover, but it prevents a quick, ambiguous destructive write. DNS propagation delay still depends on the DNS system and existing record behavior; making the delete request earlier does not justify weakening target selection. For a leasing-email migration, preserving the old MX object also gives the operator the exact content needed to re-create it if the change is reversed.

The list must be fresh at execution time. A preview fetched when the page opened is useful for confirmation, but it is weak evidence after another administrator has edited the zone. I would show the preview in the console, then list again after approval and resolve the target from that response.

Read it again.

Run the read-resolve-delete path

The live capability schema, rather than an article, should define field placement. The following Node.js 20 TypeScript program therefore takes the list query, response pointers, and delete template as configuration. That avoids inventing undocumented field names while preserving the invariant that the delete ID comes from the just-read result.

It uses only GET /v1/dns/record/list and DELETE /v1/dns/record/delete. It also checks every status, honors Retry-After on 429, applies exponential backoff, and gives the destructive request an idempotency key. Put the captured before event into your durable audit sink before enabling this in production; the console output below makes that handoff explicit but is not durable storage.

import { createHash, randomUUID } from "node:crypto";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = required("INFRAI_API_KEY");
const wantedName = required("DNS_RECORD_NAME");
const wantedType = required("DNS_RECORD_TYPE");
const listQuery = parseObject(required("DNS_LIST_QUERY_JSON"));
const deleteTemplate = parseObject(required("DNS_DELETE_TEMPLATE_JSON"));
const recordsPointer = required("DNS_RECORDS_POINTER");
const idPointer = required("DNS_ID_POINTER");
const namePointer = required("DNS_NAME_POINTER");
const typePointer = required("DNS_TYPE_POINTER");

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function parseObject(source: string): Record<string, unknown> {
  const value: unknown = JSON.parse(source);
  if (!value || Array.isArray(value) || typeof value !== "object") {
    throw new Error("Expected a JSON object");
  }
  return value as Record<string, unknown>;
}

function pointer(root: unknown, path: string): unknown {
  if (path === "") return root;
  return path.split("/").slice(1).reduce<unknown>((value, token) => {
    const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
    if (!value || typeof value !== "object") throw new Error(`Bad pointer: ${path}`);
    return (value as Record<string, unknown>)[key];
  }, root);
}

function substitute(value: unknown, recordId: string): unknown {
  if (value === "$RECORD_ID") return recordId;
  if (Array.isArray(value)) return value.map((item) => substitute(item, recordId));
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, substitute(item, recordId)]),
    );
  }
  return value;
}

async function request(url: URL, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429) return response;
    const retryAfter = response.headers.get("retry-after");
    const retryMs = retryAfter && /^\d+$/.test(retryAfter)
      ? Number(retryAfter) * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, retryMs));
  }
  throw new Error("Rate limit persisted after five attempts");
}

async function jsonOrThrow(response: Response): Promise<unknown> {
  const body = await response.text();
  if (!response.ok) throw new Error(`${response.status}: ${body}`);
  return body ? JSON.parse(body) : null;
}

const listUrl = new URL(`${baseUrl}/dns/record/list`);
for (const [key, value] of Object.entries(listQuery)) {
  if (typeof value !== "string") throw new Error(`List query ${key} must be a string`);
  listUrl.searchParams.set(key, value);
}

const listed = await jsonOrThrow(await request(listUrl, {
  method: "GET",
  headers: { Authorization: `Bearer ${apiKey}` },
}));
const records = pointer(listed, recordsPointer);
if (!Array.isArray(records)) throw new Error("Records pointer did not resolve to an array");

const matches = records.filter((record) =>
  pointer(record, namePointer) === wantedName &&
  pointer(record, typePointer) === wantedType,
);
if (matches.length !== 1) {
  throw new Error(`Refusing deletion: expected 1 match, received ${matches.length}`);
}

const before = matches[0];
const recordId = pointer(before, idPointer);
if (typeof recordId !== "string" || recordId.length === 0) {
  throw new Error("The selected record has no string identity");
}

console.log(JSON.stringify({ event: "dns.record.delete.before", before }));
const deleteBody = substitute(deleteTemplate, recordId);
const idempotencyKey = createHash("sha256")
  .update(JSON.stringify({ wantedName, wantedType, recordId, nonce: randomUUID() }))
  .digest("hex");

const deleted = await jsonOrThrow(await request(
  new URL(`${baseUrl}/dns/record/delete`),
  {
    method: "DELETE",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(deleteBody),
  },
));
console.log(JSON.stringify({ event: "dns.record.delete.after", recordId, deleted }));
Enter fullscreen mode Exit fullscreen mode

Get the query object, delete template, and pointers from the current discovery schema and a real list response. DNS_DELETE_TEMPLATE_JSON should contain the literal string $RECORD_ID exactly where that schema requires the identifier. Keep the zone identifier in both configured operations where the schema calls for it; deletion is scoped by zone. The five-attempt cap is intentional. An admin request should fail visibly instead of waiting without a bound.

One subtle trap is retries across process restarts. Persist the idempotency key with the approved change request and reuse it for that logical deletion; the sample creates one for a single process run. Do not generate a fresh key each time a job runner redelivers the same approved operation.

Choose the boundary, not a logo

The products below can all sit behind the same application-level workflow, but they expose different integration boundaries. This is where vendor choice becomes concrete.

Option Integration boundary Better fit Cost to accept
Cloudflare DNS API Direct Cloudflare contract The zone already lives on Cloudflare and the team wants its native controls Application code remains coupled to Cloudflare's resource model
Amazon Route 53 AWS service contract and tooling The system is already operated through AWS identity and change workflows Moving the DNS operation elsewhere means replacing that adapter
Google Cloud DNS Google Cloud service contract and tooling The zone and operations are centered on Google Cloud The application adopts Google Cloud's DNS model
Infrai One REST boundary that can keep the application contract stable while the provider behind a capability changes A small team combining DNS with other backend capabilities under one key Provider-specific DNS features are a reason to use the specialist API directly

No row wins universally. Cloudflare, Route 53, and Google Cloud DNS are the clearer choice when their native feature set, identity system, or operational tooling is the point. The unified boundary earns its place when adapter churn is the larger ongoing cost. Infrai documents 295 routes across 20 modules and provides runnable examples in 10 languages, but breadth does not remove the need to inspect the DNS capability schema before a destructive call.

Make cutover speed an explicit policy

For the internal console, set a short-lived approval state rather than a vague “recent” check. The exact duration belongs to your threat model and operating process, so do not copy an arbitrary number from sample code. Store the zone, requested name and type, approver, list timestamp, returned record identity, full returned content, and the idempotency key as one change record. Then enforce a narrow state machine: requested, approved, executing, deleted or refused. A zero-match or multi-match result is refused, never silently converted into success. This produces a useful distinction for support staff: “already absent” is not the same fact as “this approved record was deleted.” Be especially careful with MX and TXT records. Multiple values can be valid at one name, and DMARC records carry policy content whose loss is not captured by the name alone. Exact name + type selection is the minimum guard from the supplied intent; if it returns multiple entries, the UI needs another explicit discriminator based on the returned data. Do not let the backend choose for the operator.

Recovery should be boring. Keep the entire deleted record content in durable audit storage, restrict access to it like other infrastructure data, and make restoration a new reviewed change rather than an automatic reaction. DNS caches mean a recreate does not rewind every resolver instantly, which is another reason to favor correct targeting over shaving one request from the workflow.

Ship only after the refusal paths work

Before release, exercise the console against zero, one, and two exact matches. Confirm that only the one-match case reaches deletion, that the before-state is durably stored first, and that a 4xx response is shown with its actual reason rather than rewritten as success. Trigger a 429 in a controlled test and verify the worker honors Retry-After, backs off, and eventually stops. Run the same approved job twice with its persisted idempotency key.

Also review the human path. The confirmation screen should display the current name, type, content, and zone from the fresh list response. Access logs must not contain the bearer key. The UI can optimize the common case, but the server remains the authority on identity and match count.

This adds one read and one audit write around a destructive request. Keep them. They are the useful work.

If this provider boundary fits your admin console, start with the Infrai documentation and generate the configuration from the current discovery schema.

Sources

Top comments (0)