DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Node.js Bounded Domain Verification Polling With Customer Visible Pending Reasons

For a marketplace that gives each tenant a subdomain, make domain proof a bounded state machine and show customers the last observation, not a guessed DNS diagnosis. The deciding constraint is zone ownership: a platform-owned zone and a customer-owned zone have different authorization boundaries, even when both end in the same route.

TL;DR: create an immutable verification attempt with an expiry, a retry plan whose next run is bounded by that expiry, and one customer-visible reason such as record_not_observed_yet. A worker may check DNS again while the attempt is pending, but it must stop at the recorded expiry. Route activation should require a separate conditional transition from the verified attempt.

This avoids a familiar failure pattern. A delayed queue delivery can arrive after a tenant has abandoned a hostname, and an open-ended checker can turn that old delivery into an authorization event.

Why tenant zone choice sets the operational boundary

A platform-owned zone is appropriate when the marketplace assigns names under a domain it controls, such as shop-104.platform.example. DNS changes and routing policy stay with the platform, so the operational problem is chiefly safe tenant-to-route binding. DNS polling for ownership is usually unnecessary because the platform already controls the parent zone; the application still needs to make hostname allocation idempotent and prevent one tenant from claiming another tenant's route.

A customer-owned zone is different. The tenant controls the DNS zone and asks the platform to serve a hostname such as store.customer.example. A DNS challenge is then evidence that the requester can publish a specified record at a specified owner name. It is evidence for that request, not permanent proof that every future route change is authorized.

This is the trade-off that matters. Platform-owned zones reduce tenant DNS work but constrain branding and domain portability. Customer-owned zones preserve the customer's naming control, while requiring a proof flow, expiry policy, support language, and a rollback path. Neither model is universally better; choose per tenant class rather than trying to disguise both behind one vague domain verified flag.

DNS answers are also observed through resolvers, not directly through a customer's control panel. RFC 2181 defines a TTL as the maximum interval before a cached resource record must be discarded, and RFC 2308 specifies negative caching behavior. An absent value observed by one verifier therefore does not establish why it was absent.

Keep the challenge value out of broad logs. Record an attempt ID and a fingerprint for correlation; the setup page is the place that needs the value itself.

How should bounded domain verification polling keep a customer-visible reason?

Use a small state model: pending, verified, expired, and cancelled. While an attempt is pending, show the record name, record type, expiry, and the most recent observation. record_not_observed_yet is intentionally narrow: it tells the customer what the verifier knows without claiming propagation, a typo, a broken delegation, or an empty zone.

For example, a marketplace could return this status to the tenant settings page:

{
  "status": "pending",
  "reason": "record_not_observed_yet",
  "record": {
    "name": "_marketplace-verify.store.customer.example",
    "type": "TXT"
  },
  "expiresAt": "2026-09-16T10:30:00Z",
  "nextCheckAfterSeconds": 300
}
Enter fullscreen mode Exit fullscreen mode

nextCheckAfterSeconds is a scheduling hint. It is not a prediction about when DNS will converge.

Store the retry schedule with the attempt when it is created. In the example below, the first five intervals are 1, 3, 5, 10, and 15 minutes; later checks are 30 minutes apart, and the recorded expiry remains the hard boundary. Those numbers are application policy, not a DNS standard. Persisting them matters because a configuration change halfway through an attempt should not silently change the terms under which a tenant's request is being evaluated. A new customer request should create a new challenge and a new deadline.

There is a limit here: polling cannot diagnose the customer's DNS provider, repair a delegation, or make a cached answer disappear. When a tenant needs proof from several independent resolvers or detailed delegation investigation, an automated single-resolver workflow is the wrong tool. Give that case a support or manual-review path instead of inventing a more confident pending reason.

Make the state transition idempotent

The queue can redeliver. A manual recheck can overlap a scheduled one. The worker below assumes that each store transition updates only an attempt whose status is still pending; the returned boolean says whether this worker won the race. The Node.js application boundary can enqueue the work, but this Go worker keeps cancellation and deadlines explicit.

package verification

import (
    "context"
    "time"
)

type Attempt struct {
    ID          string
    RecordName  string
    ExpectedTXT string
    ExpiresAt   time.Time
    CheckCount  int
    Status      string
}

type Lookup interface {
    TXT(ctx context.Context, name string) ([]string, error)
}

type Store interface {
    MarkVerified(ctx context.Context, id string, checkedAt time.Time) (bool, error)
    RecordPendingCheck(ctx context.Context, id string, checkedAt time.Time) (bool, error)
    MarkExpired(ctx context.Context, id string, checkedAt time.Time) (bool, error)
    EnqueueCheck(ctx context.Context, id string, runAt time.Time) error
}

func Check(ctx context.Context, now time.Time, a Attempt, dns Lookup, store Store) error {
    if a.Status != "pending" {
        return nil
    }
    if !now.Before(a.ExpiresAt) {
        _, err := store.MarkExpired(ctx, a.ID, now)
        return err
    }

    lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    values, err := dns.TXT(lookupCtx, a.RecordName)
    if err == nil && contains(values, a.ExpectedTXT) {
        _, err = store.MarkVerified(ctx, a.ID, now)
        return err
    }

    updated, err := store.RecordPendingCheck(ctx, a.ID, now)
    if err != nil || !updated {
        return err
    }

    runAt := nextCheck(now, a.ExpiresAt, a.CheckCount+1)
    if !runAt.Before(a.ExpiresAt) {
        return nil
    }
    return store.EnqueueCheck(ctx, a.ID, runAt)
}

func nextCheck(now, expiresAt time.Time, count int) time.Time {
    delays := []time.Duration{time.Minute, 3 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute}
    delay := 30 * time.Minute
    if count <= len(delays) {
        delay = delays[count-1]
    }
    next := now.Add(delay)
    if next.After(expiresAt) {
        return expiresAt
    }
    return next
}

func contains(values []string, want string) bool {
    for _, value := range values {
        if value == want {
            return true
        }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

A five-second lookup timeout is an internal worker budget, not a statement that DNS must answer within five seconds. Treat a timeout as an incomplete check. It should drive an internal alert if it repeats, while the customer still sees the honest pending reason unless the system has stronger evidence.

Schedule the next job only after the conditional pending update succeeds. This ordering is dull on purpose: duplicate deliveries cannot multiply future work, and an on-call engineer can identify the last recorded result without reconstructing a race from queue logs.

One transition. One future check.

Verify and roll back before changing routes

Before a verified customer-owned hostname can receive traffic, re-read the attempt and confirm the immutable tenant ID, requested hostname, record name, and challenge fingerprint. Bind those fields when the attempt is created. A match on the TXT value alone is too weak when an old request and a new request can exist for the same hostname.

For a platform-owned name, perform the equivalent check against the hostname allocation record. The implementation differs, but the invariant is the same: route activation must be conditional on a current authorization record that belongs to the tenant requesting it.

Test four controlled-zone paths before release: expected TXT present, expected TXT absent, resolver timeout, and expiry. Then force a duplicate delivery for a pending attempt. The expected outcome is one terminal transition and no more than one newly scheduled job after a successful pending update.

Rollback is also a state transition. Disable the tenant route first, cancel unused verification attempts for that hostname, and retain the audit record. Deleting the evidence makes the later support question much harder: what was authorized, by whom, and under which attempt?

Treat expiry as an authorization boundary

An expiry is more than queue housekeeping. It prevents a stale challenge from being accepted after the business decision behind it has changed.

For policy records, RFC 7489 shows why exact owner names matter: DMARC discovery uses _dmarc under the domain being evaluated and follows defined organizational-domain rules. It is not a general ownership-verification protocol, but it illustrates a broader DNS rule for this workflow: record name and record content are inseparable parts of the policy.

Use platform-owned zones when a stable marketplace namespace is the product requirement. Use customer-owned zones when the tenant needs to retain naming control, then make the verification attempt bounded, observable, idempotent, and revocable. The queue should report an observation; it should never quietly become the authority to activate an outdated route.

References

Top comments (0)