@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/README.md +8 -5
- package/dist/index.cjs +132 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +132 -47
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/registrars/cloudflare.ts +2 -1
- package/src/registrars/domain101.ts +3 -1
- package/src/registrars/gandi.ts +144 -52
- package/src/types.ts +14 -0
- package/src/utils.ts +24 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoxborrow/registrar-client",
|
|
3
|
-
"version": "0.
|
|
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": "^
|
|
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
|
-
|
|
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 {
|
package/src/registrars/gandi.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
|
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
|
|
609
|
-
*
|
|
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.
|
|
616
|
-
|
|
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).
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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
|
-
*
|
|
633
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
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.
|