@aoxborrow/registrar-client 0.9.2 → 0.10.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
@@ -231,7 +231,7 @@ not a claim of available API endpoints. See [101domain](docs/registrars/101domai
231
231
  it can't be returned synchronously.
232
232
  - **ʲ** Dynadot and NameSilo forward the whole domain (a single `@` rule);
233
233
  clearing a forward restores the default nameservers.
234
- - **ᵏ** Gandi web redirects are subdomain-only — the apex (`@`) is rejected.
234
+ - **ᵏ** Gandi web redirects are per host, apex included; creating one replaces the host's DNS record.
235
235
  - **ˡ** GoDaddy domain forwarding uses the v2 customer-scoped API, so it requires
236
236
  a `customerId` (the customer UUID, or a numeric shopper ID the client resolves to
237
237
  the UUID via an sso-key-only lookup); the old v1 forwarding route was removed.
@@ -273,10 +273,13 @@ Two opt-in extended capabilities cover forwarding, both modelled as replace-all
273
273
  sets:
274
274
 
275
275
  - **Domain (URL) forwarding** — `getDomainForwarding` / `setDomainForwarding`
276
- take `DomainForward[]` (`{ host, url, type }`). `host` is apex-relative (`@`,
277
- `www`, …); `type` is `temporary` (302) or `permanent` (301). HTTPS targets work
278
- wherever the provider issues a certificate for the domain (all supported
279
- providers do).
276
+ take `DomainForward[]` (`{ host, url, type, https? }`). `host` is apex-relative
277
+ (`@`, `www`, …); `type` is `temporary` (302) or `permanent` (301). The target's
278
+ scheme is part of `url`. `https` is whether the _source_ host answers on HTTPS
279
+ (`off` / `on` / `only`, where `only` also redirects HTTP to HTTPS). Only Gandi
280
+ sets it per rule; Cloudflare is always `on`; other providers manage it
281
+ themselves, leave it unset on read, and reject an explicit value. Unset on
282
+ write keeps a rule's current mode.
280
283
  - **Email forwarding** — `getEmailForwarding` / `setEmailForwarding` take
281
284
  `EmailForward[]` (`{ alias, forwardTo }`). `alias` is the local part; `*` (or
282
285
  `@`) is the catch-all. This is alias→inbox **forwarding**, not mailbox hosting.
package/dist/index.cjs CHANGED
@@ -204,12 +204,18 @@ function sleep(ms) {
204
204
  return new Promise((resolve) => setTimeout(resolve, ms));
205
205
  }
206
206
  var MASKED_FORWARD_UNSUPPORTED = 'masked/framed forwarding is not supported by this library; use "temporary" or "permanent"';
207
- function settableForwards(forwards) {
207
+ function settableForwards(forwards, httpsModes = []) {
208
208
  for (const f of forwards) {
209
209
  if (f.type === "masked") throw new Error(MASKED_FORWARD_UNSUPPORTED);
210
+ if (f.https !== void 0 && !httpsModes.includes(f.https)) {
211
+ throw new Error(unsupportedForwardHttps(f.https, httpsModes));
212
+ }
210
213
  }
211
214
  return forwards;
212
215
  }
216
+ function unsupportedForwardHttps(mode, httpsModes = []) {
217
+ return `forwarding https "${mode}" can't be set on this provider` + (httpsModes.length ? ` (supported: ${httpsModes.map((m) => `"${m}"`).join(", ")})` : "; leave `https` unset to use its default");
218
+ }
213
219
  function requireConsent(provider, consent) {
214
220
  if (!consent) {
215
221
  throw new ConsentRequiredError(
@@ -1270,7 +1276,7 @@ var CloudflareRegistrar = class _CloudflareRegistrar extends BaseRegistrar {
1270
1276
  */
1271
1277
  async setDomainForwarding(domainName, forwards, opts) {
1272
1278
  try {
1273
- const settable = settableForwards(forwards);
1279
+ const settable = settableForwards(forwards, ["on"]);
1274
1280
  const zone = await this.requireZone(domainName, opts);
1275
1281
  const existing = await this.getRedirectRuleset(zone.id, opts);
1276
1282
  if (settable.length === 0) {
@@ -2128,6 +2134,8 @@ var Domain101Registrar = class extends BaseRegistrar {
2128
2134
  throw new ConfigurationError(
2129
2135
  "101domain: only one permanent apex forwarding rule is supported"
2130
2136
  );
2137
+ if (forwards[0]?.https !== void 0)
2138
+ throw new ConfigurationError(`101domain: ${unsupportedForwardHttps(forwards[0].https)}`);
2131
2139
  if (forwards.length) {
2132
2140
  let url;
2133
2141
  try {
@@ -2646,13 +2654,17 @@ ${bodyStr}`;
2646
2654
  );
2647
2655
  }
2648
2656
  // Auto-renew via the renew_option endpoint. The accepted write values are
2649
- // "auto" (auto-renew), "donot" (do not renew), and "reset" (manual renewal) —
2650
- // note these differ from the read values ("auto-renew" etc.).
2657
+ // "auto" (auto-renew), "donot" (do not renew), and "reset" (manual renewal);
2658
+ // they read back as "auto-renew", "do not renew", and "manual renewal". Off
2659
+ // writes "reset": off means "won't renew automatically", and "donot" would
2660
+ // also stop Dynadot's expiration reminder emails, so this never writes it.
2661
+ // "reset" is always manual renewal, even when the account's default renew
2662
+ // option is auto (sandbox-verified 2026-10-28).
2651
2663
  async setAutoRenew(domainName, enabled, opts) {
2652
2664
  return this.mutate(
2653
2665
  "PUT",
2654
2666
  `/restful/v2/domains/${encodeURIComponent(domainName)}/renew_option`,
2655
- { renew_option: enabled ? "auto" : "donot" },
2667
+ { renew_option: enabled ? "auto" : "reset" },
2656
2668
  `Auto-renew ${enabled ? "enabled" : "disabled"} successfully`,
2657
2669
  opts
2658
2670
  );
@@ -3068,6 +3080,16 @@ var GANDI_TYPE_TO_FORWARD = {
3068
3080
  cloak: "masked"
3069
3081
  // read-only; setDomainForwarding rejects it
3070
3082
  };
3083
+ var GANDI_PROTOCOL = {
3084
+ off: "http",
3085
+ on: "https",
3086
+ only: "httpsonly"
3087
+ };
3088
+ var GANDI_PROTOCOL_TO_HTTPS = {
3089
+ http: "off",
3090
+ https: "on",
3091
+ httpsonly: "only"
3092
+ };
3071
3093
  var GandiRegistrar = class extends BaseRegistrar {
3072
3094
  name = "gandi";
3073
3095
  // a REST API: GET and HEAD never change state
@@ -3082,7 +3104,7 @@ var GandiRegistrar = class extends BaseRegistrar {
3082
3104
  static supportsSandbox = true;
3083
3105
  // On top of core: transfer-out auth code (`authinfo`), DNSSEC read/disable
3084
3106
  // (LiveDNS keys), email forwarding, and web/URL forwarding (webredirs;
3085
- // per-subdomain, no apex). Note core `setPrivacy` disabling is a no-op for
3107
+ // per host, apex included). Note core `setPrivacy` disabling is a no-op for
3086
3108
  // individual (natural-person) registrants — Gandi keeps WHOIS obfuscation on
3087
3109
  // for GDPR regardless of the request.
3088
3110
  static extendedFeatures = [
@@ -3452,73 +3474,128 @@ var GandiRegistrar = class extends BaseRegistrar {
3452
3474
  }
3453
3475
  /**
3454
3476
  * Read web/URL forwarding via GET /domain/domains/{fqdn}/webredirs. Gandi keys
3455
- * each redirect by the full FQDN of the source subdomain; we normalize that back
3456
- * to a host relative to the apex (e.g. `shop.example.com` -> `shop`).
3477
+ * each redirect by the full FQDN of the source host; we normalize that back to
3478
+ * a host relative to the apex (e.g. `shop.example.com` -> `shop`, and the bare
3479
+ * domain -> `@`).
3457
3480
  */
3458
3481
  async getDomainForwarding(domainName, opts) {
3459
- const redirs = await this.http.request({
3460
- path: `/domain/domains/${domainName}/webredirs`,
3461
- ...opts
3462
- });
3463
- return (redirs ?? []).map((r) => ({
3464
- host: gandiFqdnToHost(r.host ?? domainName, domainName),
3465
- url: r.url ?? "",
3466
- type: GANDI_TYPE_TO_FORWARD[(r.type ?? "").toLowerCase()] ?? "permanent"
3467
- }));
3482
+ const redirs = await this.listWebredirs(domainName, opts);
3483
+ return redirs.map((r) => fromGandiWebredir(r, domainName));
3468
3484
  }
3469
3485
  /**
3470
- * Replace web/URL forwarding (full replace; empty clears). Gandi's webredirs
3471
- * endpoint has no update — only create (POST) and delete (DELETE by FQDN) — so
3472
- * this diffs against the current set: delete entries that are gone or changed,
3473
- * then create the desired ones that aren't already present identically.
3486
+ * Replace web/URL forwarding (full replace; empty clears). Diffs against the
3487
+ * current webredirs by host:
3488
+ * - a host that's new is created (POST),
3489
+ * - a host whose url/type/https changed is updated in place (PATCH); with
3490
+ * `https` unset it keeps the rule's existing `protocol`, so an https
3491
+ * forward stays https,
3492
+ * - a host that's no longer wanted is deleted (DELETE),
3493
+ * - an identical rule (apex included) is left untouched.
3494
+ * Creates and updates run before any delete, so a failure part-way never leaves
3495
+ * a host that was meant to keep forwarding without a rule. A failure after some
3496
+ * writes landed is reported as a partial update listing what was applied.
3497
+ *
3498
+ * The apex (`@`) is the bare domain FQDN. Creating a redirect needs Gandi's own
3499
+ * DNS record at the source host, and the apex nearly always has one already
3500
+ * (POST then fails with an opaque 500), so creates send `override: true` to
3501
+ * let Gandi replace the host's record — forwarding the host is the point.
3474
3502
  *
3475
- * Gandi only supports forwarding a subdomain, not the apex; an apex host ("@")
3476
- * throws rather than silently failing with a 500 from the API.
3503
+ * `https` maps to Gandi's `protocol` (off=http, on=https, only=httpsonly).
3504
+ * A new rule with `https` unset omits `protocol`, so Gandi applies its
3505
+ * default (http). Asking for https makes Gandi provision a Let's Encrypt
3506
+ * certificate for the source host; production does this, but the sandbox
3507
+ * can't (it 500s). Gandi repoints the host at its https redirector at once,
3508
+ * and that refuses the host until the certificate exists, so switching a
3509
+ * live rule to https can leave it unreachable for a while (see gandi.md).
3477
3510
  */
3478
3511
  async setDomainForwarding(domainName, forwards, opts) {
3512
+ const applied = [];
3479
3513
  try {
3480
- const settable = settableForwards(forwards);
3514
+ const settable = settableForwards(forwards, ["off", "on", "only"]);
3515
+ const desired = /* @__PURE__ */ new Map();
3481
3516
  for (const f of settable) {
3482
- if (!f.host || f.host === "@") {
3483
- throw new Error(
3484
- `${this.name}: web forwarding requires a subdomain host; Gandi cannot forward the apex ("@")`
3485
- );
3517
+ const host = normalizeForwardHost(f.host);
3518
+ if (desired.has(host)) {
3519
+ throw new Error(`${this.name}: duplicate web forwarding host "${host}"`);
3486
3520
  }
3521
+ desired.set(host, { ...f, host });
3487
3522
  }
3488
- const current = await this.getDomainForwarding(domainName, opts);
3489
- const same = (a, b) => a.host === b.host && a.url === b.url && a.type === b.type;
3490
- for (const existing of current) {
3491
- if (!settable.some((f) => same(f, existing))) {
3492
- await this.http.request({
3493
- method: "DELETE",
3494
- path: `/domain/domains/${domainName}/webredirs/${gandiHostToFqdn(existing.host, domainName)}`,
3495
- ...opts
3523
+ const current = /* @__PURE__ */ new Map();
3524
+ for (const r of await this.listWebredirs(domainName, opts)) {
3525
+ current.set(gandiFqdnToHost(r.host ?? domainName, domainName), r);
3526
+ }
3527
+ const writes = [];
3528
+ const webredirPath = (host) => `/domain/domains/${domainName}/webredirs/${gandiHostToFqdn(host, domainName)}`;
3529
+ for (const [host, f] of desired) {
3530
+ const existing = current.get(host);
3531
+ if (!existing) {
3532
+ writes.push({
3533
+ label: `create ${host}`,
3534
+ run: () => this.http.request({
3535
+ method: "POST",
3536
+ path: `/domain/domains/${domainName}/webredirs`,
3537
+ body: {
3538
+ host: gandiHostToFqdn(host, domainName),
3539
+ type: GANDI_FORWARD_TYPE[f.type],
3540
+ url: f.url,
3541
+ ...f.https ? { protocol: GANDI_PROTOCOL[f.https] } : {},
3542
+ override: true
3543
+ },
3544
+ ...opts
3545
+ })
3496
3546
  });
3547
+ continue;
3497
3548
  }
3498
- }
3499
- for (const f of settable) {
3500
- if (!current.some((c) => same(c, f))) {
3501
- await this.http.request({
3502
- method: "POST",
3503
- path: `/domain/domains/${domainName}/webredirs`,
3504
- // `protocol` is intentionally omitted so Gandi applies its default
3505
- // (http). Requesting `https` makes Gandi provision a Let's Encrypt
3506
- // certificate for the source host, which our normalized model doesn't
3507
- // express (and which the sandbox can't do — it 500s).
3549
+ const now = fromGandiWebredir(existing, domainName);
3550
+ const httpsSame = f.https === void 0 || f.https === now.https;
3551
+ if (now.url === f.url && now.type === f.type && httpsSame) continue;
3552
+ const protocol = f.https ? GANDI_PROTOCOL[f.https] : existing.protocol;
3553
+ writes.push({
3554
+ label: `update ${host}`,
3555
+ run: () => this.http.request({
3556
+ method: "PATCH",
3557
+ path: webredirPath(host),
3508
3558
  body: {
3509
- host: gandiHostToFqdn(f.host, domainName),
3510
3559
  type: GANDI_FORWARD_TYPE[f.type],
3511
- url: f.url
3560
+ url: f.url,
3561
+ ...protocol ? { protocol } : {}
3512
3562
  },
3513
3563
  ...opts
3514
- });
3564
+ })
3565
+ });
3566
+ }
3567
+ for (const host of current.keys()) {
3568
+ if (desired.has(host)) continue;
3569
+ writes.push({
3570
+ label: `delete ${host}`,
3571
+ run: () => this.http.request({ method: "DELETE", path: webredirPath(host), ...opts })
3572
+ });
3573
+ }
3574
+ for (const w of writes) {
3575
+ try {
3576
+ await w.run();
3577
+ } catch (error) {
3578
+ if (applied.length === 0) throw error;
3579
+ const rest = writes.slice(applied.length + 1).map((x) => x.label);
3580
+ return {
3581
+ success: false,
3582
+ message: `Domain forwarding partially updated: ${w.label} failed (${toRegistrarError(error).message}); applied: ${applied.join(", ")}` + (rest.length ? `; not attempted: ${rest.join(", ")}` : "")
3583
+ };
3515
3584
  }
3585
+ applied.push(w.label);
3516
3586
  }
3517
3587
  return { success: true, message: "Domain forwarding updated successfully" };
3518
3588
  } catch (error) {
3519
3589
  return { success: false, message: toRegistrarError(error).message };
3520
3590
  }
3521
3591
  }
3592
+ async listWebredirs(domainName, opts) {
3593
+ const redirs = await this.http.request({
3594
+ path: `/domain/domains/${domainName}/webredirs`,
3595
+ ...opts
3596
+ });
3597
+ return redirs ?? [];
3598
+ }
3522
3599
  async setAutoRenew(domainName, enabled, opts) {
3523
3600
  try {
3524
3601
  await this.http.request({
@@ -3706,6 +3783,18 @@ function gandiFqdnToHost(fqdn, domain) {
3706
3783
  const suffix = `.${domain}`;
3707
3784
  return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
3708
3785
  }
3786
+ function normalizeForwardHost(host) {
3787
+ return host ? host : "@";
3788
+ }
3789
+ function fromGandiWebredir(r, domain) {
3790
+ const https = GANDI_PROTOCOL_TO_HTTPS[(r.protocol ?? "").toLowerCase()];
3791
+ return {
3792
+ host: gandiFqdnToHost(r.host ?? domain, domain),
3793
+ url: r.url ?? "",
3794
+ type: GANDI_TYPE_TO_FORWARD[(r.type ?? "").toLowerCase()] ?? "permanent",
3795
+ ...https ? { https } : {}
3796
+ };
3797
+ }
3709
3798
  function fromGandiContact(c) {
3710
3799
  if (!c) return void 0;
3711
3800
  return {
@@ -8486,6 +8575,7 @@ exports.settableForwards = settableForwards;
8486
8575
  exports.sleep = sleep;
8487
8576
  exports.toDnsEntry = toDnsEntry;
8488
8577
  exports.toRegistrarError = toRegistrarError;
8578
+ exports.unsupportedForwardHttps = unsupportedForwardHttps;
8489
8579
  exports.useLabelMapper = useLabelMapper;
8490
8580
  exports.wasNotSent = wasNotSent;
8491
8581
  //# sourceMappingURL=index.cjs.map