@aoxborrow/registrar-client 0.9.2 → 0.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aoxborrow/registrar-client",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
5
5
  "author": {
6
6
  "name": "Aaron Oxborrow",
@@ -100,7 +100,7 @@
100
100
  "eslint-plugin-import-x": "^4.17.1",
101
101
  "globals": "^17.12.0",
102
102
  "prettier": "^3.9.8",
103
- "tsup": "^8.5.0",
103
+ "tsup": "^9.0.0",
104
104
  "typescript": "^5.9.3",
105
105
  "typescript-eslint": "^8.71.1",
106
106
  "vitest": "^4.1.11"
@@ -575,7 +575,8 @@ export class CloudflareRegistrar extends BaseRegistrar {
575
575
  opts?: RequestOptions
576
576
  ): Promise<OperationResult> {
577
577
  try {
578
- const settable = settableForwards(forwards); // reject masked before any write
578
+ // reject masked before any write; HTTPS is always on (Universal SSL)
579
+ const settable = settableForwards(forwards, ['on']);
579
580
  const zone = await this.requireZone(domainName, opts);
580
581
  const existing = await this.getRedirectRuleset(zone.id, opts);
581
582
 
@@ -26,7 +26,7 @@ import type {
26
26
  RequestOptions,
27
27
  TldPricing,
28
28
  } from '../types';
29
- import { createDomain, filterDomains, normalizeDomain } from '../utils';
29
+ import { createDomain, filterDomains, normalizeDomain, unsupportedForwardHttps } from '../utils';
30
30
 
31
31
  // 101domain Client API 1.1.0, https://api.101domain.com/api/documentation
32
32
  interface Envelope<T> {
@@ -516,6 +516,8 @@ export class Domain101Registrar extends BaseRegistrar {
516
516
  throw new ConfigurationError(
517
517
  '101domain: only one permanent apex forwarding rule is supported'
518
518
  );
519
+ if (forwards[0]?.https !== undefined)
520
+ throw new ConfigurationError(`101domain: ${unsupportedForwardHttps(forwards[0].https)}`);
519
521
  if (forwards.length) {
520
522
  let url: URL;
521
523
  try {
@@ -8,6 +8,7 @@ import type {
8
8
  Domain,
9
9
  DomainAvailability,
10
10
  DomainForward,
11
+ DomainForwardHttps,
11
12
  DomainForwardType,
12
13
  DsRecord,
13
14
  EmailForward,
@@ -20,7 +21,12 @@ import type {
20
21
  TransferDomainInput,
21
22
  } from '../types';
22
23
  import { REST_SAFE_METHODS } from '../http';
23
- import { createDomain, filterDomains, settableForwards } from '../utils';
24
+ import {
25
+ createDomain,
26
+ filterDomains,
27
+ settableForwards,
28
+ type SettableDomainForward,
29
+ } from '../utils';
24
30
  import { toRegistrarError } from '../errors';
25
31
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
26
32
  import { Feature, type RegistrarFeature } from '../features';
@@ -130,7 +136,8 @@ interface GandiForward {
130
136
 
131
137
  // a web-forwarding (URL redirect) entry from GET
132
138
  // /v5/domain/domains/{fqdn}/webredirs. `host` is the full FQDN of the source
133
- // subdomain; `type` is Gandi's redirect kind (http301/http302/cloak).
139
+ // host (the bare domain for the apex); `type` is Gandi's redirect kind
140
+ // (http301/http302/cloak); `protocol` is http/https/httpsonly.
134
141
  interface GandiWebredir {
135
142
  host?: string;
136
143
  type?: string;
@@ -148,6 +155,17 @@ const GANDI_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
148
155
  http302: 'temporary',
149
156
  cloak: 'masked', // read-only; setDomainForwarding rejects it
150
157
  };
158
+ // map our DomainForwardHttps to Gandi's webredir `protocol`, and back.
159
+ const GANDI_PROTOCOL: Record<DomainForwardHttps, string> = {
160
+ off: 'http',
161
+ on: 'https',
162
+ only: 'httpsonly',
163
+ };
164
+ const GANDI_PROTOCOL_TO_HTTPS: Record<string, DomainForwardHttps> = {
165
+ http: 'off',
166
+ https: 'on',
167
+ httpsonly: 'only',
168
+ };
151
169
 
152
170
  /**
153
171
  * Gandi.net Registrar
@@ -176,7 +194,7 @@ export class GandiRegistrar extends BaseRegistrar {
176
194
  static readonly supportsSandbox = true;
177
195
  // On top of core: transfer-out auth code (`authinfo`), DNSSEC read/disable
178
196
  // (LiveDNS keys), email forwarding, and web/URL forwarding (webredirs;
179
- // per-subdomain, no apex). Note core `setPrivacy` disabling is a no-op for
197
+ // per host, apex included). Note core `setPrivacy` disabling is a no-op for
180
198
  // individual (natural-person) registrants — Gandi keeps WHOIS obfuscation on
181
199
  // for GDPR regardless of the request.
182
200
  static readonly extendedFeatures: readonly RegistrarFeature[] = [
@@ -605,79 +623,132 @@ export class GandiRegistrar extends BaseRegistrar {
605
623
 
606
624
  /**
607
625
  * Read web/URL forwarding via GET /domain/domains/{fqdn}/webredirs. Gandi keys
608
- * each redirect by the full FQDN of the source subdomain; we normalize that back
609
- * to a host relative to the apex (e.g. `shop.example.com` -> `shop`).
626
+ * each redirect by the full FQDN of the source host; we normalize that back to
627
+ * a host relative to the apex (e.g. `shop.example.com` -> `shop`, and the bare
628
+ * domain -> `@`).
610
629
  */
611
630
  override async getDomainForwarding(
612
631
  domainName: string,
613
632
  opts?: RequestOptions
614
633
  ): Promise<DomainForward[]> {
615
- const redirs = await this.http.request<GandiWebredir[]>({
616
- path: `/domain/domains/${domainName}/webredirs`,
617
- ...opts,
618
- });
619
- return (redirs ?? []).map(r => ({
620
- host: gandiFqdnToHost(r.host ?? domainName, domainName),
621
- url: r.url ?? '',
622
- type: GANDI_TYPE_TO_FORWARD[(r.type ?? '').toLowerCase()] ?? 'permanent',
623
- }));
634
+ const redirs = await this.listWebredirs(domainName, opts);
635
+ return redirs.map(r => fromGandiWebredir(r, domainName));
624
636
  }
625
637
 
626
638
  /**
627
- * Replace web/URL forwarding (full replace; empty clears). Gandi's webredirs
628
- * endpoint has no update — only create (POST) and delete (DELETE by FQDN) — so
629
- * this diffs against the current set: delete entries that are gone or changed,
630
- * then create the desired ones that aren't already present identically.
639
+ * Replace web/URL forwarding (full replace; empty clears). Diffs against the
640
+ * current webredirs by host:
641
+ * - a host that's new is created (POST),
642
+ * - a host whose url/type/https changed is updated in place (PATCH); with
643
+ * `https` unset it keeps the rule's existing `protocol`, so an https
644
+ * forward stays https,
645
+ * - a host that's no longer wanted is deleted (DELETE),
646
+ * - an identical rule (apex included) is left untouched.
647
+ * Creates and updates run before any delete, so a failure part-way never leaves
648
+ * a host that was meant to keep forwarding without a rule. A failure after some
649
+ * writes landed is reported as a partial update listing what was applied.
631
650
  *
632
- * Gandi only supports forwarding a subdomain, not the apex; an apex host ("@")
633
- * throws rather than silently failing with a 500 from the API.
651
+ * The apex (`@`) is the bare domain FQDN. Creating a redirect needs Gandi's own
652
+ * DNS record at the source host, and the apex nearly always has one already
653
+ * (POST then fails with an opaque 500), so creates send `override: true` to
654
+ * let Gandi replace the host's record — forwarding the host is the point.
655
+ *
656
+ * `https` maps to Gandi's `protocol` (off=http, on=https, only=httpsonly).
657
+ * A new rule with `https` unset omits `protocol`, so Gandi applies its
658
+ * default (http). Asking for https makes Gandi provision a Let's Encrypt
659
+ * certificate for the source host; production does this, but the sandbox
660
+ * can't (it 500s). Gandi repoints the host at its https redirector at once,
661
+ * and that refuses the host until the certificate exists, so switching a
662
+ * live rule to https can leave it unreachable for a while (see gandi.md).
634
663
  */
635
664
  override async setDomainForwarding(
636
665
  domainName: string,
637
666
  forwards: DomainForward[],
638
667
  opts?: RequestOptions
639
668
  ): Promise<OperationResult> {
669
+ const applied: string[] = [];
640
670
  try {
641
- const settable = settableForwards(forwards); // reject masked before any write
671
+ // reject masked before any write
672
+ const settable = settableForwards(forwards, ['off', 'on', 'only']);
673
+ const desired = new Map<string, SettableDomainForward>();
642
674
  for (const f of settable) {
643
- if (!f.host || f.host === '@') {
644
- throw new Error(
645
- `${this.name}: web forwarding requires a subdomain host; Gandi cannot forward the apex ("@")`
646
- );
675
+ const host = normalizeForwardHost(f.host);
676
+ if (desired.has(host)) {
677
+ throw new Error(`${this.name}: duplicate web forwarding host "${host}"`);
647
678
  }
679
+ desired.set(host, { ...f, host });
680
+ }
681
+ const current = new Map<string, GandiWebredir>();
682
+ for (const r of await this.listWebredirs(domainName, opts)) {
683
+ current.set(gandiFqdnToHost(r.host ?? domainName, domainName), r);
648
684
  }
649
- const current = await this.getDomainForwarding(domainName, opts);
650
- const same = (a: DomainForward, b: DomainForward): boolean =>
651
- a.host === b.host && a.url === b.url && a.type === b.type;
652
685
 
653
- // delete current entries that are no longer wanted, or whose url/type changed
654
- for (const existing of current) {
655
- if (!settable.some(f => same(f, existing))) {
656
- await this.http.request({
657
- method: 'DELETE',
658
- path: `/domain/domains/${domainName}/webredirs/${gandiHostToFqdn(existing.host, domainName)}`,
659
- ...opts,
686
+ const writes: { label: string; run: () => Promise<unknown> }[] = [];
687
+ const webredirPath = (host: string): string =>
688
+ `/domain/domains/${domainName}/webredirs/${gandiHostToFqdn(host, domainName)}`;
689
+ for (const [host, f] of desired) {
690
+ const existing = current.get(host);
691
+ if (!existing) {
692
+ writes.push({
693
+ label: `create ${host}`,
694
+ run: () =>
695
+ this.http.request({
696
+ method: 'POST',
697
+ path: `/domain/domains/${domainName}/webredirs`,
698
+ body: {
699
+ host: gandiHostToFqdn(host, domainName),
700
+ type: GANDI_FORWARD_TYPE[f.type],
701
+ url: f.url,
702
+ ...(f.https ? { protocol: GANDI_PROTOCOL[f.https] } : {}),
703
+ override: true,
704
+ },
705
+ ...opts,
706
+ }),
660
707
  });
708
+ continue;
661
709
  }
710
+ const now = fromGandiWebredir(existing, domainName);
711
+ const httpsSame = f.https === undefined || f.https === now.https;
712
+ if (now.url === f.url && now.type === f.type && httpsSame) continue; // unchanged
713
+ const protocol = f.https ? GANDI_PROTOCOL[f.https] : existing.protocol;
714
+ writes.push({
715
+ label: `update ${host}`,
716
+ run: () =>
717
+ this.http.request({
718
+ method: 'PATCH',
719
+ path: webredirPath(host),
720
+ body: {
721
+ type: GANDI_FORWARD_TYPE[f.type],
722
+ url: f.url,
723
+ ...(protocol ? { protocol } : {}),
724
+ },
725
+ ...opts,
726
+ }),
727
+ });
662
728
  }
663
- // create desired entries that aren't already present identically
664
- for (const f of settable) {
665
- if (!current.some(c => same(c, f))) {
666
- await this.http.request({
667
- method: 'POST',
668
- path: `/domain/domains/${domainName}/webredirs`,
669
- // `protocol` is intentionally omitted so Gandi applies its default
670
- // (http). Requesting `https` makes Gandi provision a Let's Encrypt
671
- // certificate for the source host, which our normalized model doesn't
672
- // express (and which the sandbox can't do — it 500s).
673
- body: {
674
- host: gandiHostToFqdn(f.host, domainName),
675
- type: GANDI_FORWARD_TYPE[f.type],
676
- url: f.url,
677
- },
678
- ...opts,
679
- });
729
+ for (const host of current.keys()) {
730
+ if (desired.has(host)) continue;
731
+ writes.push({
732
+ label: `delete ${host}`,
733
+ run: () => this.http.request({ method: 'DELETE', path: webredirPath(host), ...opts }),
734
+ });
735
+ }
736
+
737
+ for (const w of writes) {
738
+ try {
739
+ await w.run();
740
+ } catch (error) {
741
+ if (applied.length === 0) throw error;
742
+ const rest = writes.slice(applied.length + 1).map(x => x.label);
743
+ return {
744
+ success: false,
745
+ message:
746
+ `Domain forwarding partially updated: ${w.label} failed ` +
747
+ `(${toRegistrarError(error).message}); applied: ${applied.join(', ')}` +
748
+ (rest.length ? `; not attempted: ${rest.join(', ')}` : ''),
749
+ };
680
750
  }
751
+ applied.push(w.label);
681
752
  }
682
753
  return { success: true, message: 'Domain forwarding updated successfully' };
683
754
  } catch (error) {
@@ -685,6 +756,14 @@ export class GandiRegistrar extends BaseRegistrar {
685
756
  }
686
757
  }
687
758
 
759
+ private async listWebredirs(domainName: string, opts?: RequestOptions): Promise<GandiWebredir[]> {
760
+ const redirs = await this.http.request<GandiWebredir[]>({
761
+ path: `/domain/domains/${domainName}/webredirs`,
762
+ ...opts,
763
+ });
764
+ return redirs ?? [];
765
+ }
766
+
688
767
  override async setAutoRenew(
689
768
  domainName: string,
690
769
  enabled: boolean,
@@ -928,6 +1007,19 @@ function gandiFqdnToHost(fqdn: string, domain: string): string {
928
1007
  const suffix = `.${domain}`;
929
1008
  return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
930
1009
  }
1010
+ // our apex spelling: an empty host also means the apex.
1011
+ function normalizeForwardHost(host: string): string {
1012
+ return host ? host : '@';
1013
+ }
1014
+ function fromGandiWebredir(r: GandiWebredir, domain: string): DomainForward {
1015
+ const https = GANDI_PROTOCOL_TO_HTTPS[(r.protocol ?? '').toLowerCase()];
1016
+ return {
1017
+ host: gandiFqdnToHost(r.host ?? domain, domain),
1018
+ url: r.url ?? '',
1019
+ type: GANDI_TYPE_TO_FORWARD[(r.type ?? '').toLowerCase()] ?? 'permanent',
1020
+ ...(https ? { https } : {}),
1021
+ };
1022
+ }
931
1023
 
932
1024
  function fromGandiContact(c: GandiContact | undefined): Contact | undefined {
933
1025
  if (!c) return undefined;
package/src/types.ts CHANGED
@@ -295,6 +295,14 @@ export interface EmailForward {
295
295
  // it at all, so the library never creates one.
296
296
  export type DomainForwardType = 'temporary' | 'permanent' | 'masked';
297
297
 
298
+ // Whether the forward's SOURCE host answers on HTTPS (the provider holds a
299
+ // certificate for it); the destination's scheme is part of `url`.
300
+ // off — HTTP only.
301
+ // on — HTTP and HTTPS.
302
+ // only — HTTPS, with HTTP requests redirected to HTTPS.
303
+ // Only some providers let you choose; see `DomainForward.https`.
304
+ export type DomainForwardHttps = 'off' | 'on' | 'only';
305
+
298
306
  // A URL/domain forwarding rule: requests to `host` at the domain are redirected
299
307
  // to `url`.
300
308
  export interface DomainForward {
@@ -305,6 +313,12 @@ export interface DomainForward {
305
313
  url: string;
306
314
  // the redirect style
307
315
  type: DomainForwardType;
316
+ // source-host HTTPS mode, when the provider exposes it per rule (Gandi; on
317
+ // Cloudflare it is always 'on'). Reads leave it unset when the provider has no
318
+ // such setting. On write, unset keeps the rule's current mode (or the
319
+ // provider's default for a new rule); a value the provider can't apply is
320
+ // rejected before any write.
321
+ https?: DomainForwardHttps;
308
322
  }
309
323
 
310
324
  // A DNSSEC delegation-signer (DS) record published at the parent zone. Fields
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DomainForward,
3
+ DomainForwardHttps,
3
4
  Domain,
4
5
  DomainInput,
5
6
  RegistrationConsent,
@@ -18,16 +19,35 @@ export type SettableDomainForward = DomainForward & { type: 'temporary' | 'perma
18
19
  export const MASKED_FORWARD_UNSUPPORTED =
19
20
  'masked/framed forwarding is not supported by this library; use "temporary" or "permanent"';
20
21
 
21
- // Guard for `setDomainForwarding`: reject any masked/framed forward up front (so
22
- // no partial write happens) and narrow the list to the settable types. Providers
23
- // call this before writing.
24
- export function settableForwards(forwards: DomainForward[]): SettableDomainForward[] {
22
+ // Guard for `setDomainForwarding`: reject any masked/framed forward, and any
23
+ // `https` mode outside `httpsModes` (what the provider can apply), up front (so
24
+ // no partial write happens), and narrow the list to the settable types.
25
+ // Providers call this before writing.
26
+ export function settableForwards(
27
+ forwards: DomainForward[],
28
+ httpsModes: readonly DomainForwardHttps[] = []
29
+ ): SettableDomainForward[] {
25
30
  for (const f of forwards) {
26
31
  if (f.type === 'masked') throw new Error(MASKED_FORWARD_UNSUPPORTED);
32
+ if (f.https !== undefined && !httpsModes.includes(f.https)) {
33
+ throw new Error(unsupportedForwardHttps(f.https, httpsModes));
34
+ }
27
35
  }
28
36
  return forwards as SettableDomainForward[];
29
37
  }
30
38
 
39
+ export function unsupportedForwardHttps(
40
+ mode: DomainForwardHttps,
41
+ httpsModes: readonly DomainForwardHttps[] = []
42
+ ): string {
43
+ return (
44
+ `forwarding https "${mode}" can't be set on this provider` +
45
+ (httpsModes.length
46
+ ? ` (supported: ${httpsModes.map(m => `"${m}"`).join(', ')})`
47
+ : '; leave `https` unset to use its default')
48
+ );
49
+ }
50
+
31
51
  // guard for register/transfer: registration forms a legal contract with the
32
52
  // registry, so a provider must be given explicit consent. Throws
33
53
  // ConsentRequiredError (distinct from NotImplementedError) when it's absent.