@aoxborrow/registrar-client 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -425,6 +425,35 @@ sandbox (Name.com offers no domain deletion/reset there); it is excluded from th
425
425
  default offline suite. Transfer completion and production behavior need separate
426
426
  verification; sandbox transfers are not deterministic.
427
427
 
428
+ ## DNS record syntax
429
+
430
+ Pure functions for checking DNS record syntax and putting records in one
431
+ canonical form, with no network calls, so a UI can validate what someone types
432
+ before it reaches a registrar.
433
+
434
+ ```ts
435
+ import { findCnameConflicts, prepareEntry, toDnsEntry } from '@aoxborrow/registrar-client';
436
+
437
+ // throws DnsSyntaxError with a message safe to show a person
438
+ prepareEntry(toDnsEntry({ type: 'MX', name: '@', value: 'Mail.Example.net.', priority: 10 }));
439
+ // → { owner: '@', kind: 'MX', ttl: 3600, rdata: '10 mail.example.net' }
440
+
441
+ findCnameConflicts(records.map(r => toDnsEntry(r))); // [{ owner, kind, detail }]
442
+ ```
443
+
444
+ - Names are folded to lowercase with one trailing dot removed, and are never
445
+ joined to a zone: `www` and `www.example.com` are different owners, and `@`
446
+ stays `@`. Underscore labels (`_dmarc`) and one leftmost `*` are allowed.
447
+ - Data syntax is checked for A, AAAA, CNAME, NS, MX (including null MX), TXT,
448
+ SRV, CAA and DS. Any other type is trimmed and passed through, so a
449
+ provider-specific type still round-trips.
450
+ - `findCnameConflicts` and `findRepeatedRecords` check a whole set of records.
451
+ - `toDnsEntry` adapts a `DnsRecord` (separate MX/SRV number fields) to the
452
+ entry shape the checks take.
453
+
454
+ These are DNS rules only. Each provider still applies its own limits, such as
455
+ a minimum TTL or the record types it accepts.
456
+
428
457
  ## Sandbox environments
429
458
 
430
459
  Providers that offer a test environment accept `{ environment: 'sandbox' }` at
package/dist/index.cjs CHANGED
@@ -2238,20 +2238,32 @@ function fromRecord(raw) {
2238
2238
  if (raw.type === "MX") {
2239
2239
  const match = /^(\d+)\s+(.+)$/.exec(raw.value);
2240
2240
  if (!match) throw invalid("MX record");
2241
- return { ...common, value: match[2], priority: Number(match[1]) };
2241
+ return { ...common, value: canonicalValue("MX", match[2]), priority: Number(match[1]) };
2242
2242
  }
2243
2243
  if (raw.type === "SRV") {
2244
2244
  const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/.exec(raw.value);
2245
2245
  if (!match) throw invalid("SRV record");
2246
2246
  return {
2247
2247
  ...common,
2248
- value: match[4],
2248
+ value: canonicalValue("SRV", match[4]),
2249
2249
  priority: Number(match[1]),
2250
2250
  weight: Number(match[2]),
2251
2251
  port: Number(match[3])
2252
2252
  };
2253
2253
  }
2254
- return { ...common, value: raw.value };
2254
+ return { ...common, value: canonicalValue(raw.type, raw.value) };
2255
+ }
2256
+ function canonicalValue(type, value) {
2257
+ const kind = type.toUpperCase();
2258
+ if (kind === "TXT") return unquoteTxt(value);
2259
+ if (["CNAME", "NS", "MX", "SRV"].includes(kind))
2260
+ return value === "." ? "." : value.replace(/\.$/, "");
2261
+ return value;
2262
+ }
2263
+ function unquoteTxt(value) {
2264
+ const chunk = /"((?:[^"\\]|\\.)*)"/g;
2265
+ if (!/^\s*"(?:[^"\\]|\\.)*"(?:\s+"(?:[^"\\]|\\.)*")*\s*$/.test(value)) return value;
2266
+ return [...value.matchAll(chunk)].map((m) => m[1].replace(/\\(.)/g, "$1")).join("");
2255
2267
  }
2256
2268
  function recordSpec(record, domain, strict = true) {
2257
2269
  const type = record.type.toUpperCase();
@@ -2272,7 +2284,7 @@ function recordSpec(record, domain, strict = true) {
2272
2284
  if (!Number.isInteger(record.priority) || record.priority < 0 || record.priority > 65535)
2273
2285
  reject("MX/SRV priority is required");
2274
2286
  result.priority = record.priority;
2275
- result.target = record.value;
2287
+ result.target = canonicalValue(type, record.value);
2276
2288
  if (type === "SRV") {
2277
2289
  if (![record.weight, record.port].every((n) => Number.isInteger(n) && n >= 0 && n <= 65535))
2278
2290
  reject("SRV weight and port are required");
@@ -2289,7 +2301,7 @@ function recordSpec(record, domain, strict = true) {
2289
2301
  result.tag = match[2];
2290
2302
  result.value = match[3];
2291
2303
  }
2292
- } else result.value = record.value;
2304
+ } else result.value = canonicalValue(type, record.value);
2293
2305
  return result;
2294
2306
  }
2295
2307
  function recordSignature(record) {
@@ -8006,6 +8018,330 @@ function createRegistrar(name, credentials, options) {
8006
8018
  return new RegistrarClass(credentials, options);
8007
8019
  }
8008
8020
 
8021
+ // src/dns-records.ts
8022
+ var DnsSyntaxError = class extends Error {
8023
+ constructor(message) {
8024
+ super(message);
8025
+ this.name = "DnsSyntaxError";
8026
+ }
8027
+ };
8028
+ var NAME_LIMIT = 253;
8029
+ var LABEL_LIMIT = 63;
8030
+ var TTL_LIMIT = 2147483647;
8031
+ var TEXT_OCTETS = 255;
8032
+ var TEXT_TOTAL_OCTETS = 65535 - Math.ceil(65535 / (TEXT_OCTETS + 1));
8033
+ var UINT16_MAX = 65535;
8034
+ var DIGEST_HEX_LENGTH = {
8035
+ 1: 40,
8036
+ 2: 64,
8037
+ 3: 64,
8038
+ 4: 96
8039
+ };
8040
+ var KINDS_ALLOWED_BESIDE_CNAME = /* @__PURE__ */ new Set(["CNAME", "RRSIG", "NSEC", "NSEC3"]);
8041
+ function mapAsciiLabel(label) {
8042
+ const folded = label.toLowerCase();
8043
+ if (folded.length < 1 || folded.length > LABEL_LIMIT) return null;
8044
+ if (!/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(folded)) return null;
8045
+ return folded;
8046
+ }
8047
+ var labelMapper = mapAsciiLabel;
8048
+ function useLabelMapper(mapper) {
8049
+ labelMapper = mapper;
8050
+ }
8051
+ function resetLabelMapper() {
8052
+ labelMapper = mapAsciiLabel;
8053
+ }
8054
+ function prepareName(input, role = "owner") {
8055
+ const trimmed = input.trim();
8056
+ if (!trimmed) throw new DnsSyntaxError("Name is empty");
8057
+ const bare = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed;
8058
+ if (!bare) throw new DnsSyntaxError("Name is empty");
8059
+ if (bare === "@") {
8060
+ if (role === "target") throw new DnsSyntaxError("Origin token @ cannot be a record target");
8061
+ return "@";
8062
+ }
8063
+ const labels = bare.split(".");
8064
+ if (labels.some((label) => label.length === 0)) {
8065
+ throw new DnsSyntaxError("Name contains an empty label");
8066
+ }
8067
+ const starLabels = labels.filter((label) => label.includes("*"));
8068
+ if (starLabels.length > 0) {
8069
+ if (role !== "owner") {
8070
+ throw new DnsSyntaxError("A wildcard is only valid as an owner name");
8071
+ }
8072
+ const starCount = starLabels.reduce(
8073
+ (count, label) => count + (label.match(/\*/g)?.length ?? 0),
8074
+ 0
8075
+ );
8076
+ if (starCount > 1) throw new DnsSyntaxError("A name can contain only one wildcard");
8077
+ if (labels[0] !== "*") throw new DnsSyntaxError("A wildcard must be its own leftmost label");
8078
+ }
8079
+ const mapped = labels.map((label) => {
8080
+ if (label === "*") return "*";
8081
+ const ascii = labelMapper(label);
8082
+ if (!ascii) throw new DnsSyntaxError(`Label is not a valid DNS label: ${label}`);
8083
+ return ascii.toLowerCase();
8084
+ });
8085
+ const name = mapped.join(".");
8086
+ if (name.length > NAME_LIMIT) throw new DnsSyntaxError("Name is longer than 253 characters");
8087
+ return name;
8088
+ }
8089
+ function isIpv4(text2) {
8090
+ const octets = text2.split(".");
8091
+ if (octets.length !== 4) return false;
8092
+ return octets.every((octet) => {
8093
+ if (!/^\d{1,3}$/.test(octet)) return false;
8094
+ const value = Number(octet);
8095
+ return value >= 0 && value <= 255 && String(value) === octet;
8096
+ });
8097
+ }
8098
+ function isIpv6(text2) {
8099
+ let body = text2;
8100
+ const embedded = text2.match(/^(.+):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
8101
+ if (embedded) {
8102
+ if (!isIpv4(embedded[2])) return false;
8103
+ body = `${embedded[1]}:0:0`;
8104
+ }
8105
+ const compressedAt = body.indexOf("::");
8106
+ if (compressedAt !== -1) {
8107
+ if (body.indexOf("::", compressedAt + 1) !== -1) return false;
8108
+ const groupsOf = (side) => side === "" ? [] : side.split(":");
8109
+ const head = groupsOf(body.slice(0, compressedAt));
8110
+ const tail = groupsOf(body.slice(compressedAt + 2));
8111
+ if (head.length + tail.length > 7) return false;
8112
+ return [...head, ...tail].every((group) => /^[0-9a-fA-F]{1,4}$/.test(group));
8113
+ }
8114
+ const groups = body.split(":");
8115
+ return groups.length === 8 && groups.every((group) => /^[0-9a-fA-F]{1,4}$/.test(group));
8116
+ }
8117
+ function utf8OctetCount(text2) {
8118
+ let count = 0;
8119
+ for (const char of text2) {
8120
+ const code = char.codePointAt(0) ?? 0;
8121
+ if (code <= 127) count += 1;
8122
+ else if (code <= 2047) count += 2;
8123
+ else if (code <= 65535) count += 3;
8124
+ else count += 4;
8125
+ }
8126
+ return count;
8127
+ }
8128
+ function decimalInRange(token, limit) {
8129
+ if (!/^(0|[1-9][0-9]*)$/.test(token)) return null;
8130
+ const value = Number(token);
8131
+ if (value > limit) return null;
8132
+ return value;
8133
+ }
8134
+ function prepareIpv4(text2) {
8135
+ if (!isIpv4(text2)) throw new DnsSyntaxError("IPv4 address is invalid");
8136
+ return text2;
8137
+ }
8138
+ function prepareIpv6(text2) {
8139
+ if (!isIpv6(text2)) throw new DnsSyntaxError("IPv6 address is invalid");
8140
+ return text2.toLowerCase();
8141
+ }
8142
+ function prepareHostTarget(text2, owner, rejectSelf) {
8143
+ const withoutDot = text2.endsWith(".") ? text2.slice(0, -1) : text2;
8144
+ if (isIpv4(withoutDot) || isIpv6(withoutDot)) {
8145
+ throw new DnsSyntaxError("Target must be a domain name, not an address");
8146
+ }
8147
+ const target = prepareName(text2, "target");
8148
+ if (rejectSelf && owner !== void 0 && prepareName(owner, "owner") === target) {
8149
+ throw new DnsSyntaxError("CNAME target is the same name as its owner");
8150
+ }
8151
+ return target;
8152
+ }
8153
+ function prepareMailExchange(text2) {
8154
+ const parts = text2.split(/\s+/);
8155
+ if (parts.length !== 2) {
8156
+ throw new DnsSyntaxError('MX data must be "<priority> <mail host>"');
8157
+ }
8158
+ const priority = decimalInRange(parts[0], UINT16_MAX);
8159
+ if (priority === null) throw new DnsSyntaxError("MX priority must be an integer from 0 to 65535");
8160
+ return `${priority} ${prepareServiceHost(parts[1])}`;
8161
+ }
8162
+ function prepareServiceHost(text2) {
8163
+ return text2 === "." ? "." : prepareHostTarget(text2, void 0, false);
8164
+ }
8165
+ function prepareService(text2) {
8166
+ const parts = text2.split(/\s+/);
8167
+ if (parts.length !== 4) {
8168
+ throw new DnsSyntaxError('SRV data must be "<priority> <weight> <port> <target>"');
8169
+ }
8170
+ const [priority, weight, port] = parts.slice(0, 3).map((part) => decimalInRange(part, UINT16_MAX));
8171
+ if (priority === null)
8172
+ throw new DnsSyntaxError("SRV priority must be an integer from 0 to 65535");
8173
+ if (weight === null) throw new DnsSyntaxError("SRV weight must be an integer from 0 to 65535");
8174
+ if (port === null) throw new DnsSyntaxError("SRV port must be an integer from 0 to 65535");
8175
+ return `${priority} ${weight} ${port} ${prepareServiceHost(parts[3])}`;
8176
+ }
8177
+ function prepareAuthorization(text2) {
8178
+ const match = /^(\S+)\s+(\S+)\s+([\s\S]+)$/.exec(text2);
8179
+ if (!match) throw new DnsSyntaxError('CAA data must be "<flags> <tag> <value>"');
8180
+ const flags = decimalInRange(match[1], 255);
8181
+ if (flags === null) throw new DnsSyntaxError("CAA flags must be an integer from 0 to 255");
8182
+ if (!/^[a-z0-9]{1,15}$/i.test(match[2])) {
8183
+ throw new DnsSyntaxError("CAA tag must be 1 to 15 letters or digits");
8184
+ }
8185
+ const quoted = /^"((?:[^"\\]|\\.)*)"$/.exec(match[3]);
8186
+ const value = quoted ? quoted[1] : match[3];
8187
+ if (!quoted && value.includes('"')) {
8188
+ throw new DnsSyntaxError("CAA value must be one quoted or unquoted string");
8189
+ }
8190
+ return `${flags} ${match[2].toLowerCase()} "${value}"`;
8191
+ }
8192
+ function prepareText(text2) {
8193
+ if (utf8OctetCount(text2) > TEXT_TOTAL_OCTETS) {
8194
+ throw new DnsSyntaxError(`TXT data is longer than ${TEXT_TOTAL_OCTETS} octets`);
8195
+ }
8196
+ return text2;
8197
+ }
8198
+ function readSignerFields(text2) {
8199
+ const parts = text2.trim().split(/\s+/);
8200
+ if (parts.length !== 4)
8201
+ throw new DnsSyntaxError('DS data must be "<key tag> <algorithm> <digest type> <digest>"');
8202
+ const keyTag = decimalInRange(parts[0], UINT16_MAX);
8203
+ const algorithm = decimalInRange(parts[1], 255);
8204
+ const digestType = decimalInRange(parts[2], 255);
8205
+ if (keyTag === null || algorithm === null || digestType === null || !parts[3]) {
8206
+ throw new DnsSyntaxError('DS data must be "<key tag> <algorithm> <digest type> <digest>"');
8207
+ }
8208
+ const digest = parts[3].toLowerCase();
8209
+ if (!/^[0-9a-f]+$/.test(digest)) throw new DnsSyntaxError("DS digest must be hexadecimal");
8210
+ const expectedLength = DIGEST_HEX_LENGTH[digestType];
8211
+ if (expectedLength !== void 0 && digest.length !== expectedLength) {
8212
+ throw new DnsSyntaxError(
8213
+ `DS digest must be ${expectedLength} hex characters for digest type ${digestType}`
8214
+ );
8215
+ }
8216
+ return { keyTag, algorithm, digestType, digest };
8217
+ }
8218
+ function readDelegationSigner(rdata) {
8219
+ return readSignerFields(rdata);
8220
+ }
8221
+ function prepareSignerText(text2) {
8222
+ const signer = readSignerFields(text2);
8223
+ return `${signer.keyTag} ${signer.algorithm} ${signer.digestType} ${signer.digest}`;
8224
+ }
8225
+ function prepareRdata(kind, rdata, owner) {
8226
+ const token = kind.trim().toUpperCase();
8227
+ const text2 = rdata.trim();
8228
+ if (!text2) throw new DnsSyntaxError("Record data is empty");
8229
+ switch (token) {
8230
+ case "A":
8231
+ return prepareIpv4(text2);
8232
+ case "AAAA":
8233
+ return prepareIpv6(text2);
8234
+ case "CNAME":
8235
+ return prepareHostTarget(text2, owner, true);
8236
+ case "NS":
8237
+ return prepareHostTarget(text2, void 0, false);
8238
+ case "MX":
8239
+ return prepareMailExchange(text2);
8240
+ case "TXT":
8241
+ return prepareText(text2);
8242
+ case "SRV":
8243
+ return prepareService(text2);
8244
+ case "CAA":
8245
+ return prepareAuthorization(text2);
8246
+ case "DS":
8247
+ return prepareSignerText(text2);
8248
+ default:
8249
+ return text2;
8250
+ }
8251
+ }
8252
+ function prepareEntry(entry) {
8253
+ if (!Number.isInteger(entry.ttl) || entry.ttl < 0 || entry.ttl > TTL_LIMIT) {
8254
+ throw new DnsSyntaxError("TTL must be an integer from 0 to 2147483647");
8255
+ }
8256
+ const kind = entry.kind.trim().toUpperCase();
8257
+ if (!kind) throw new DnsSyntaxError("Record type is empty");
8258
+ return {
8259
+ owner: prepareName(entry.owner, "owner"),
8260
+ kind,
8261
+ ttl: entry.ttl,
8262
+ rdata: prepareRdata(kind, entry.rdata, entry.owner)
8263
+ };
8264
+ }
8265
+ function syntaxClash(entry, error) {
8266
+ const detail = error instanceof Error ? error.message : "Invalid record";
8267
+ return { owner: entry.owner, kind: entry.kind, detail };
8268
+ }
8269
+ function findCnameConflicts(entries) {
8270
+ const clashes = [];
8271
+ const byOwner = /* @__PURE__ */ new Map();
8272
+ for (const entry of entries) {
8273
+ let owner;
8274
+ let kind;
8275
+ try {
8276
+ owner = prepareName(entry.owner, "owner");
8277
+ kind = entry.kind.trim().toUpperCase();
8278
+ if (!kind) throw new DnsSyntaxError("Record type is empty");
8279
+ } catch (error) {
8280
+ clashes.push(syntaxClash(entry, error));
8281
+ continue;
8282
+ }
8283
+ const group = byOwner.get(owner) ?? [];
8284
+ group.push({ ...entry, owner, kind });
8285
+ byOwner.set(owner, group);
8286
+ }
8287
+ for (const [owner, group] of byOwner) {
8288
+ const aliasCount = group.filter((entry) => entry.kind === "CNAME").length;
8289
+ if (aliasCount === 0) continue;
8290
+ if (aliasCount > 1) {
8291
+ clashes.push({
8292
+ owner,
8293
+ kind: "CNAME",
8294
+ detail: "A name can have only one CNAME target"
8295
+ });
8296
+ }
8297
+ const others = [...new Set(group.map((entry) => entry.kind))].filter(
8298
+ (kind) => !KINDS_ALLOWED_BESIDE_CNAME.has(kind)
8299
+ );
8300
+ if (others.length > 0) {
8301
+ clashes.push({
8302
+ owner,
8303
+ kind: "CNAME",
8304
+ detail: `A CNAME cannot share a name with ${others.join(", ")}`
8305
+ });
8306
+ }
8307
+ }
8308
+ return clashes;
8309
+ }
8310
+ function findRepeatedRecords(entries) {
8311
+ const clashes = [];
8312
+ const seen = /* @__PURE__ */ new Set();
8313
+ for (const entry of entries) {
8314
+ let prepared;
8315
+ try {
8316
+ prepared = prepareEntry(entry);
8317
+ } catch (error) {
8318
+ clashes.push(syntaxClash(entry, error));
8319
+ continue;
8320
+ }
8321
+ if (prepared.kind === "CNAME") continue;
8322
+ const key = `${prepared.owner}\0${prepared.kind}\0${prepared.rdata}`;
8323
+ if (seen.has(key)) {
8324
+ clashes.push({
8325
+ owner: prepared.owner,
8326
+ kind: prepared.kind,
8327
+ detail: `Repeated ${prepared.kind} data at this name`
8328
+ });
8329
+ }
8330
+ seen.add(key);
8331
+ }
8332
+ return clashes;
8333
+ }
8334
+ function toDnsEntry(record, defaultTtl = 3600) {
8335
+ const kind = record.type.trim().toUpperCase();
8336
+ const numbers = kind === "MX" ? [record.priority] : kind === "SRV" ? [record.priority, record.weight, record.port] : [];
8337
+ return {
8338
+ owner: record.name,
8339
+ kind,
8340
+ ttl: record.ttl ?? defaultTtl,
8341
+ rdata: [...numbers.map((n) => n === void 0 ? "" : String(n)), record.value.trim()].join(" ")
8342
+ };
8343
+ }
8344
+
8009
8345
  exports.ALL_FEATURES = ALL_FEATURES;
8010
8346
  exports.AbortError = AbortError;
8011
8347
  exports.AuthenticationError = AuthenticationError;
@@ -8017,6 +8353,7 @@ exports.ConfigurationError = ConfigurationError;
8017
8353
  exports.ConnectionError = ConnectionError;
8018
8354
  exports.ConsentRequiredError = ConsentRequiredError;
8019
8355
  exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
8356
+ exports.DnsSyntaxError = DnsSyntaxError;
8020
8357
  exports.Domain101Registrar = Domain101Registrar;
8021
8358
  exports.DynadotRegistrar = DynadotRegistrar;
8022
8359
  exports.EXTENDED_FEATURES = EXTENDED_FEATURES;
@@ -8050,19 +8387,30 @@ exports.createDomain = createDomain;
8050
8387
  exports.createRegistrar = createRegistrar;
8051
8388
  exports.ensureArray = ensureArray;
8052
8389
  exports.filterDomains = filterDomains;
8390
+ exports.findCnameConflicts = findCnameConflicts;
8391
+ exports.findRepeatedRecords = findRepeatedRecords;
8053
8392
  exports.isCoreFeature = isCoreFeature;
8393
+ exports.isIpv4 = isIpv4;
8394
+ exports.isIpv6 = isIpv6;
8054
8395
  exports.listPortfolio = listPortfolio;
8055
8396
  exports.markNotSent = markNotSent;
8056
8397
  exports.normalizeDomain = normalizeDomain;
8057
8398
  exports.normalizeNameservers = normalizeNameservers;
8058
8399
  exports.parseDate = parseDate;
8059
8400
  exports.parseXml = parseXml;
8401
+ exports.prepareEntry = prepareEntry;
8402
+ exports.prepareName = prepareName;
8403
+ exports.prepareRdata = prepareRdata;
8404
+ exports.readDelegationSigner = readDelegationSigner;
8060
8405
  exports.registrars = registrars;
8061
8406
  exports.requireConsent = requireConsent;
8407
+ exports.resetLabelMapper = resetLabelMapper;
8062
8408
  exports.selectBaseUrl = selectBaseUrl;
8063
8409
  exports.settableForwards = settableForwards;
8064
8410
  exports.sleep = sleep;
8411
+ exports.toDnsEntry = toDnsEntry;
8065
8412
  exports.toRegistrarError = toRegistrarError;
8413
+ exports.useLabelMapper = useLabelMapper;
8066
8414
  exports.wasNotSent = wasNotSent;
8067
8415
  //# sourceMappingURL=index.cjs.map
8068
8416
  //# sourceMappingURL=index.cjs.map