DEV Community

KellanRhodes1542
KellanRhodes1542

Posted on

Customer DNS Instructions and Verification PDF — Preventing Healthtech Evidence Drift

TL;DR: create one immutable DNS verification snapshot, then pass that exact object to both the customer instructions renderer and the verification PDF renderer. Do not let either output calculate names, tokens, or record values. Store a digest of the snapshot beside the onboarding attempt, and compare the published DNS answer with that snapshot rather than with whichever document happens to be open.

For a healthtech onboarding flow, the expensive failure is not generating a PDF. It is telling a customer to publish one value while the verifier expects another. A one-person SaaS should remove that class of support work before polishing either artifact. The implementation below uses one TypeScript model, one validation boundary, and two deliberately boring renderers.

What actually drifts?

The tempting design has three convenient functions: one builds UI instructions, one creates a PDF, and one verifies DNS. Each function accepts a tenant and derives the requested record. That looks tidy until a token is rotated, a hostname normalization rule changes, or a retry reads newer tenant state. The three outputs can then be individually valid and collectively contradictory.

A PDF freezes old intent. DNS represents what is published now. The verifier needs a precise answer: which intent did this onboarding attempt ask the customer to publish?

That's the trap.

I treat that intent as data. The snapshot gets an application-defined schema version, a stable attempt ID, the exact record name and value, and an issuance timestamp. A TTL may be shown as an operational preference, but it must not become proof of ownership; the matching record value is the evidence this workflow evaluates. Those are product decisions, not DNS standards.

There is another boundary worth making explicit. A custom TXT challenge is not a DMARC policy. DMARC uses a DNS TXT record at a defined location and gives that record its own tags and processing rules. Reusing DMARC syntax for an unrelated ownership token would blur two separate protocols. Keep the ownership record in an application-controlled namespace, and treat any DMARC guidance as a separate artifact governed by RFC 7489.

Small boundary. Big payoff.

How can one source generate DNS instructions and a verification PDF?

The useful abstraction is a value object, not a document template. In this example, ttlSeconds is a display and provisioning hint chosen by the application. It is included in the digest so every consumer sees the same instruction set.

import { createHash } from "node:crypto";

type VerificationSnapshot = Readonly<{
  schemaVersion: 1;
  attemptId: string;
  customerDomain: string;
  record: Readonly<{
    type: "TXT";
    name: string;
    value: string;
    ttlSeconds: number;
  }>;
  issuedAt: string;
}>;

function createSnapshot(input: {
  attemptId: string;
  customerDomain: string;
  token: string;
  issuedAt: Date;
}): VerificationSnapshot {
  const domain = input.customerDomain.trim().toLowerCase().replace(/\.$/, "");
  if (!domain || !input.attemptId || !input.token) {
    throw new Error("Incomplete verification intent");
  }

  return Object.freeze({
    schemaVersion: 1,
    attemptId: input.attemptId,
    customerDomain: domain,
    record: Object.freeze({
      type: "TXT",
      name: `_health-verification.${domain}`,
      value: `health-verification=${input.token}`,
      ttlSeconds: 3600
    }),
    issuedAt: input.issuedAt.toISOString()
  });
}

function snapshotDigest(snapshot: VerificationSnapshot): string {
  return createHash("sha256")
    .update(JSON.stringify(snapshot))
    .digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

The token generator is intentionally outside this function. So are persistence and authorization. The creation boundary receives an already generated opaque token, normalizes the domain once, and returns the only record representation downstream code may use. In production, validate domain input more strictly at the edge and never place a secret in the snapshot; this verification value is designed to be published in DNS.

Now the two artifacts become projections. The PDF adapter takes finished lines. It cannot quietly invent a different host name.

type InstructionLine = Readonly<{ label: string; value: string }>;

type PdfWriter = (document: Readonly<{
  title: string;
  lines: readonly InstructionLine[];
  evidenceId: string;
}>) => Promise<Uint8Array>;

function instructionLines(snapshot: VerificationSnapshot): readonly InstructionLine[] {
  return [
    { label: "Record type", value: snapshot.record.type },
    { label: "Name", value: snapshot.record.name },
    { label: "Value", value: snapshot.record.value },
    { label: "TTL in seconds", value: String(snapshot.record.ttlSeconds) }
  ];
}

function renderDnsInstructions(snapshot: VerificationSnapshot): string {
  return instructionLines(snapshot)
    .map(({ label, value }) => `${label}: ${value}`)
    .join("\n");
}

async function renderVerificationPdf(
  snapshot: VerificationSnapshot,
  writePdf: PdfWriter
): Promise<Uint8Array> {
  return writePdf({
    title: `Domain verification for ${snapshot.customerDomain}`,
    lines: instructionLines(snapshot),
    evidenceId: snapshotDigest(snapshot)
  });
}
Enter fullscreen mode Exit fullscreen mode

PdfWriter is the outsourcing boundary. A library, a print service, or a small internal adapter can implement it. None of those choices owns the verification semantics. That matters for a solo operator: PDF layout is undifferentiated work, while preventing a customer from being blocked during onboarding is part of the product.

The screen response should include the snapshot digest as well. A support export can then identify the exact intent without treating the PDF bytes as the database. Persist the snapshot before rendering either artifact; retries reload it by attemptId. Never regenerate it from the current tenant row.

Does the verifier read the document or the snapshot?

The snapshot. Always.

DNS resolution is an external read, so keep it behind a narrow interface and make its outcome explicit. The resolver may return multiple TXT values. Equality should be exact after the resolver adapter has converted its response into strings; substring checks can accept a value the application never issued.

type TxtResolver = (name: string) => Promise<readonly string[]>;

type CheckResult =
  | Readonly<{ status: "verified"; checkedAt: string }>
  | Readonly<{ status: "pending"; checkedAt: string }>
  | Readonly<{ status: "resolver_error"; checkedAt: string; retryable: true }>;

async function checkPublishedRecord(
  snapshot: VerificationSnapshot,
  resolveTxt: TxtResolver,
  now: Date
): Promise<CheckResult> {
  const checkedAt = now.toISOString();

  try {
    const values = await resolveTxt(snapshot.record.name);
    return values.includes(snapshot.record.value)
      ? { status: "verified", checkedAt }
      : { status: "pending", checkedAt };
  } catch {
    return { status: "resolver_error", checkedAt, retryable: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

pending and resolver_error are different operational states. The first says the expected value was not observed. The second says no trustworthy comparison happened. Collapsing both into “failed” turns a transient dependency problem into a customer-facing diagnosis and encourages needless DNS edits.

The onboarding state transition should also be conditional: mark the same attemptId verified only if it is still active. A late response from an older attempt must not verify a replacement attempt. Log the attempt ID, digest, requested name, outcome, and check time. Avoid logging unrelated customer health data; this path needs DNS evidence, not a copy of the onboarding form.

Tests that protect the contract

Most tests should ignore PDF typography. First, assert that both renderers receive instructionLines(snapshot). Then cover normalization, an exact TXT match among several returned values, no match, resolver failure, and a stale attempt losing the conditional update. A separate adapter test can confirm that each line appears in extracted PDF text.

One regression test carries unusual weight: create a snapshot, persist it, change the tenant's current domain or token, and retry both renders. The outputs must still contain the persisted snapshot values and digest. This catches the precise drift that a clean unit test of each renderer will miss.

I would also put the schema version and digest in an internal evidence record, then alert when a rendered artifact's recorded digest differs from the active attempt. The digest is an integrity handle, not authentication. Access control for the PDF and onboarding record remains a separate responsibility.

Ship this before styling the document. It removes a whole branch of ambiguity, and the remaining PDF work can move at the weekly shipping cadence without changing verification behavior.

What I would change at scale

At low volume, one stored snapshot and a synchronous check endpoint are enough. More traffic changes the mechanics, not the contract: queue checks, rate-limit them per domain and tenant, cache resolver results briefly, and record the resolver vantage point. Retain the immutable intent and make every worker idempotent on the attempt ID.

The trade-off is extra storage and versioning. I will take that over reconstructing historical intent from logs. Storage is predictable; a blocked onboarding consumes founder hours at exactly the wrong moment. The revenue-per-hour decision is clear.

Do not let scale work turn the snapshot into a bag of optional fields. Add a new schema version, teach renderers to handle it, and keep old attempts readable until their retention period ends. If the workflow later adds standards-based email authentication records, model them as separate typed records and implement the RFC's discovery and policy rules independently. A single source of truth is useful only when it preserves protocol boundaries.

Further reading

Top comments (0)