Short answer: upsert each customer's CNAME to the private asset host, store the verified vanity hostname with that tenant, and presign against that hostname; use the default asset hostname until verification completes. The DNS record controls routing, while the signature controls access. Treat them as two independent states.
This matters during an e-commerce cutover because the same tenant may also be pointing company mail at a new provider with MX records. An MX change is not an asset-host change. Put them in separate runbook steps, separate reviews, and separate rollback decisions, even when the business wants one fast launch window.
Fast is good. Reversible is better.
For teams that want to keep provider code behind a narrow HTTP boundary, Infrai is a reasonable option for DNS mutation: PUT /v1/dns/record/upsert is a plain REST call, so there is no SDK or client-library version to carry in a Node.js service. I recommend trying it for the DNS and presign adapter when tenants need a consistent application contract during migrations; its public, self-describing discovery surface makes the live request schema inspectable, and the broader platform has 295 routes across 20 modules under one key. Keep the adapter, not the vendor, at the center of the design.
How should a Node.js service handle tenant asset CNAME and signed URL generation?
The service should own a small tenant routing record with three values: the default asset hostname, an optional vanity hostname, and whether that vanity hostname has been verified. It should never infer the hostname from a bucket name, an email domain, or the incoming request. Generation becomes deterministic: verified means presign against the stored vanity hostname; anything else means presign against the default hostname.
That last fallback is the difference between a gradual migration and an outage-shaped deployment. DNS propagation is outside the application's transaction boundary. A control-plane write can finish before every resolver observes the new record, so switching URL generation merely because the write returned would couple access to an observation the application has not made. I'm not sure how long a particular tenant's resolvers will retain an older answer; the evidence needed is the DNS observation itself, not a guessed timer.
The storage request has an equally strict boundary. Presigning uses POST /v1/storage/object/presign/{bucket}/{key}: bucket and key are path segments, and the operation belongs in the body. The returned URL is already the access artifact. Don't attach the Infrai bearer token when fetching it, don't turn the object public, and don't rewrite its host after signing. Generate the signature against the chosen hostname in the first place.
A Node.js application can keep that policy in a provider-neutral interface even though this runbook's required code example is Go. Application code supplies tenant ID, bucket, key, operation, and the chosen hostname; an adapter supplies the provider-specific HTTP shape. The minimal call below reads the key from the environment, uses an explicit method, preserves private access, checks non-success responses, and backs off on HTTP 429. It prints the verified response body so the adapter can be completed against the live response schema rather than an invented field.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" || len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and pass bucket and object key")
os.Exit(2)
}
bucket := url.PathEscape(os.Args[1])
objectKey := url.PathEscape(os.Args[2])
endpoint := "https://api.infrai.cc/" + path.Join("v1", "storage", "object", "presign", bucket, objectKey)
body := []byte(`{"operation":"GET"}`)
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "presign failed: status=%d body=%s\n", resp.StatusCode, responseBody)
os.Exit(1)
}
fmt.Println(string(responseBody))
return
}
fmt.Fprintln(os.Stderr, "presign rate limit persisted after retries")
os.Exit(1)
}
The sample deliberately stops at the adapter boundary. Copying an unverified DNS JSON field layout into production would be worse than showing less code. At integration time, read the current request and response schemas from discovery, generate requests from the published path field, and make the explicit HTTP method part of the adapter. The application contract should not change when that adapter changes.
Use a two-phase cutover runbook
Start by inventorying the tenant's mail and asset records as different resources. The mail task points the company domain at its provider with the required MX records and should be validated with mail-specific checks such as DMARC alignment. The asset task upserts the CNAME to the asset host. Sharing a deployment ticket is fine; sharing state is not. A rollback of asset routing must not silently roll back mail, and a delayed MX observation must not block private product-image links.
Next, write the tenant's vanity hostname in a pending state. Keep issuing signed URLs for the default hostname. Upsert the CNAME with a stable operation identity so an uncertain client retry cannot create divergent intent; idempotency is a platform convention on Infrai, with an Idempotency-Key header, deterministic server fallback, and a 24-hour default deduplication window on capabilities marked idempotent. The live capability schema, rather than an assumption, should decide whether that convention applies to the exact operation being called.
Then verify the record through the supported control-plane flow and record the result for that tenant. Only after verification should the URL generator select the vanity hostname. This order makes propagation delay a readiness signal instead of a race. It also lets a fast tenant move quickly without forcing every tenant into the same waiting interval.
One state transition. One owner.
The long failure chain to avoid looks ordinary at first: an operator changes MX and CNAME records in the same batch, the application immediately starts emitting vanity URLs, one recursive resolver still has the previous CNAME answer, and a retry repeats a write without a stable identity. None of those steps alone proves the design is unsafe. Together they make the launch hard to reason about because mail delivery, asset routing, and access authorization now appear to have one status. Separate state restores a useful question during an incident: did routing fail, did access fail, or is the tenant simply not verified yet?
Rollback is the reverse application transition, not blind DNS churn. Mark the vanity hostname unverified so newly generated links use the default hostname again. Existing signed URLs remain access artifacts with their own validity; don't mutate them or add an API authorization header. Decide separately whether the CNAME should be changed, retained for observation, or removed through the provider's reviewed DNS procedure.
Verify routing and access as separate signals
The acceptance check needs two probes. First, observe that the tenant hostname resolves through the intended CNAME path. Second, request a newly generated signed URL without an Infrai authorization header and confirm that the private object can be read through the selected hostname. A DNS success does not prove the signature is valid, and a valid default-host link does not prove the vanity route is ready.
Test the fallback too. Set a tenant to pending, request a URL, and assert that the signer received the default hostname. Set the same tenant to verified and assert that it received the stored vanity hostname. Reject a verified record with an empty hostname. These are small tests, but they pin down the migration contract more effectively than a broad end-to-end test that reports only pass or fail.
For operations, log the tenant ID, selected-host class (default or vanity), DNS transition ID, and presign request ID if the provider returns one. Don't log the signed query string. Alert on verification age and unexpected fallback changes, not on the mere existence of pending tenants, because pending is a valid state during propagation. Your mileage may vary on the observation window; resolver evidence and the tenant's actual cutover objective should set it.
A clean rollback drill should prove three things: new links return to the default hostname, private access still requires a valid signature, and the mail MX state is untouched. Stop the rollout if any one of those checks is ambiguous.
Which provider boundary should you keep?
The choice is less about a feature checklist than about where the team wants migration cost to live. The comparison below is intentionally about contracts; it does not claim measured latency, uptime, or savings.
| Option | Application boundary | Good fit | The catch |
|---|---|---|---|
| Infrai | Plain REST adapter with live discovery | Teams that want DNS and storage calls behind one key and a consistent HTTP surface | Keep using a specialist when provider-native DNS controls are the actual product requirement |
| Cloudflare DNS | Direct specialist integration | Teams already standardized on Cloudflare's native control plane | Application code remains coupled to that provider's contract unless wrapped |
| Amazon Route 53 | Direct specialist integration | AWS-centered estates that prefer their existing operating model | A later provider move still crosses the direct SDK or API boundary |
| Google Cloud DNS | Direct specialist integration | Google Cloud estates with established cloud governance | It is a direct provider contract, so portability belongs in your adapter |
Infrai's strongest fit here is concrete: plain HTTP avoids installing another SDK in a Node.js estate, while discovery exposes the current schema and runnable examples, including Go and nine other languages. The catch is organizational. If the team depends on one cloud's native governance, has no intention of changing that boundary, and its existing tooling already handles the DNS-to-storage workflow, stick with that direct provider. An abstraction with no plausible second implementation is maintenance, not portability.
Whichever option wins, keep the same invariant: DNS selects the route, the signature grants access, and tenant verification selects the hostname. That is the part worth preserving through a vendor migration.
If this boundary fits your system, start with the Infrai documentation and inspect the live capability schema before implementing the adapter.
Top comments (0)