@gusnips/one-click-dns 0.1.3 → 0.1.5
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 +52 -7
- package/dist/{chunk-4XNRV7QR.js → chunk-LLWYWMJ5.js} +6 -2
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/server/index.cjs +23 -3
- package/dist/server/index.d.cts +6 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.js +18 -2
- package/dist/writers/index.cjs +6 -2
- package/dist/writers/index.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -62,17 +62,27 @@ When the write fails, it throws a `DnsWriteError` with a `code`. Word each code
|
|
|
62
62
|
| `conflict` | The name already has another record (`params.type`). They decide what to do with it. |
|
|
63
63
|
| `unavailable` | The host did not answer. Try again. |
|
|
64
64
|
|
|
65
|
-
Four hosts have a writer today. What the person pastes is different for each.
|
|
65
|
+
Four hosts have a writer today. What the person pastes is different for each. The tests use a fake network. Cloudflare and DigitalOcean were also run against a real account, with made-up underscore names that were deleted after. Porkbun and Name.com were not:
|
|
66
66
|
|
|
67
|
-
| Writer | `token` is | What it needs | How far it is checked
|
|
68
|
-
| -------------- | ----------------------- | ----------------------------------------------------------------------- |
|
|
69
|
-
| `cloudflare` | An API token | `Zone:Read` and `DNS:Edit` |
|
|
70
|
-
| `porkbun` | `apikey:secretapikey` | "API access" turned on for the domain in Porkbun (it is off by default) | Written from the host's API spec. Never run against a real account
|
|
71
|
-
| `digitalocean` | A personal access token | `domain:read` and `domain:create`, or full access |
|
|
72
|
-
| `name-com` | `username:apitoken` | API access turned on in Account Settings, Security | Written from the host's docs. Never run against a real account
|
|
67
|
+
| Writer | `token` is | What it needs | How far it is checked |
|
|
68
|
+
| -------------- | ----------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
69
|
+
| `cloudflare` | An API token | `Zone:Read` and `DNS:Edit` | Run against a real zone on 2026-10-29: create, repeat, TXT, conflicts and a wrong token |
|
|
70
|
+
| `porkbun` | `apikey:secretapikey` | "API access" turned on for the domain in Porkbun (it is off by default) | Written from the host's API spec. Never run against a real account |
|
|
71
|
+
| `digitalocean` | A personal access token | `domain:read` and `domain:create`, or full access | Run against a real domain on 2026-10-29: create, repeat, TXT, conflicts and a wrong token |
|
|
72
|
+
| `name-com` | `username:apitoken` | API access turned on in Account Settings, Security | Written from the host's docs. Never run against a real account |
|
|
73
73
|
|
|
74
74
|
`writers` maps a host id to its writer, so `writers[host]` is `undefined` for a host with no writer.
|
|
75
75
|
|
|
76
|
+
### Check a writer against your own account
|
|
77
|
+
|
|
78
|
+
The repository has a script that tries a writer on a real zone and removes what it made. Use a spare zone and a token limited to it:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
HOST=cloudflare ZONE=example.com TOKEN=your-token bun run live
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
It writes a CNAME and a TXT record under made-up underscore names, runs each a second time (both must read as "unchanged"), checks that a clashing record is refused and that a wrong token is reported as one, then deletes its records. Cloudflare and DigitalOcean are supported.
|
|
85
|
+
|
|
76
86
|
## One-click links (Domain Connect)
|
|
77
87
|
|
|
78
88
|
A link needs a template that your DNS host has switched on, and a key pair. You publish the public key in DNS and keep the private key on your server.
|
|
@@ -102,6 +112,8 @@ When the host has your template, `plan.methods` starts with a `domain-connect` m
|
|
|
102
112
|
|
|
103
113
|
Only these providers are asked, because their `_domainconnect` value and settings were read from DNS: Cloudflare, Vercel, Simply.com and GoDaddy. A name in a customer's DNS is never fetched. More providers join the same way, one value at a time.
|
|
104
114
|
|
|
115
|
+
A provider listed here is one the package can talk to. It is not a promise that the provider will switch your template on. GoDaddy, for one, told us on 2026-10-29 that it is not accepting new templates for direct onboarding and gave no date. Ask each provider, and use `templateCoverage` to see who said yes. Where there is no link, `plan.methods` still ends with the steps for that host.
|
|
116
|
+
|
|
105
117
|
### Which providers have switched your template on
|
|
106
118
|
|
|
107
119
|
A merged template is not a template a provider uses. Each provider turns templates on by hand, and the only way to know is to ask it. `templateCoverage` asks every provider in the list once:
|
|
@@ -147,6 +159,39 @@ A template belongs to the company that files it. It names your key domain, and t
|
|
|
147
159
|
|
|
148
160
|
To file a template: test it in the [online editor](https://domainconnect.paulonet.eu/dc/free/templateedit), run `dc-template-linter -merge-or-fail` on it, open a pull request, then ask each DNS host to switch it on.
|
|
149
161
|
|
|
162
|
+
### Keep separate SPF services
|
|
163
|
+
|
|
164
|
+
Two links for the same template can replace each other's records, even if you check DNS before issuing either link. For SPF, that can remove another service's permission to send.
|
|
165
|
+
|
|
166
|
+
When one template serves several sending services, opt in to separate instances:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const template = spfTemplate(identity, { multiInstance: true, version: 2 });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
File the updated template and wait for the DNS provider to switch it on. Before signing a link, require that provider's copy to advertise the flag:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { discoverConnect, systemDnsLookups } from "@gusnips/one-click-dns/server";
|
|
176
|
+
|
|
177
|
+
const discovery = await discoverConnect(
|
|
178
|
+
{ txt: systemDnsLookups(2000).txt },
|
|
179
|
+
{
|
|
180
|
+
zone: "acme.com",
|
|
181
|
+
providerId: "example.com",
|
|
182
|
+
serviceId: "spf",
|
|
183
|
+
budgetMs: 2000,
|
|
184
|
+
requireMultiInstance: true,
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
This check needs both a provider entry with `supportsMultiInstance: true` and the literal template flag `multiInstance: true`. Missing or false support returns `not_offered`. The template check reads the same size-limited response and makes no extra request. Other discovery calls keep their current behavior.
|
|
190
|
+
|
|
191
|
+
No built-in provider has verified instance support yet. [Cloudflare says it ignores this flag](https://developers.cloudflare.com/dns/reference/domain-connect/#template-definition); the other providers are unverified. Requiring instances therefore returns `not_offered` for each built-in provider. Keep a manual fallback. For a custom `providers` list, set `supportsMultiInstance: true` only after verifying that provider's behavior, not just its template JSON.
|
|
192
|
+
|
|
193
|
+
Only sign when `discovery.found` is true. The signed sync link carries the template flag; the spec defines `instanceId` for the async apply and revert, not for the sync parameters. If you later remove one application through the async API, pass a stable `instanceId` derived from the full sending domain and the service's SPF include: the same pair reuses the same id, and different domains or services use different ids. The package does not choose that id for you. The flag does not check SPF's lookup limit or make a redirect back to your app proof that DNS changed.
|
|
194
|
+
|
|
150
195
|
## Steps for the host
|
|
151
196
|
|
|
152
197
|
`DNS_HOSTS` holds 33 hosts: how to recognise them from their name servers, the labels on their DNS page and the traps (a proxy on by default, a trailing dot, a TXT that needs quotes). The package holds data and no sentences, so you write the steps in your own words and languages. `dnsRecordRow` spells a record the way a host wants it typed:
|
|
@@ -81,23 +81,26 @@ var API = "https://api.cloudflare.com/client/v4";
|
|
|
81
81
|
var TIMEOUT_MS = 1e4;
|
|
82
82
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
83
83
|
var TOKEN_CODES = /* @__PURE__ */ new Set([1e3, 6003, 6111, 1e4]);
|
|
84
|
+
var INVALID_TOKEN_MESSAGE = /invalid access token/i;
|
|
84
85
|
var CONFLICT_CODES = /* @__PURE__ */ new Set([81053, 81054, 81057]);
|
|
85
86
|
var ID = /^[A-Za-z0-9]{1,64}$/;
|
|
86
87
|
var RECORD_TYPE = /^[A-Z0-9]{1,12}$/;
|
|
87
88
|
function readEnvelope(json) {
|
|
88
89
|
if (!isObject(json) || typeof json.success !== "boolean") return null;
|
|
89
90
|
const codes = [];
|
|
91
|
+
const messages = [];
|
|
90
92
|
if (Array.isArray(json.errors)) {
|
|
91
93
|
for (const error of json.errors) {
|
|
92
94
|
if (!isObject(error)) return null;
|
|
93
95
|
if (typeof error.code === "number") codes.push(error.code);
|
|
96
|
+
if (typeof error.message === "string") messages.push(error.message);
|
|
94
97
|
if (Array.isArray(error.error_chain)) {
|
|
95
98
|
for (const link of error.error_chain)
|
|
96
99
|
if (isObject(link) && typeof link.code === "number") codes.push(link.code);
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
return { success: json.success, codes, result: json.result };
|
|
103
|
+
return { success: json.success, codes, messages, result: json.result };
|
|
101
104
|
}
|
|
102
105
|
async function call(doFetch, token, path, send) {
|
|
103
106
|
const res = await fetchBounded(
|
|
@@ -124,7 +127,8 @@ async function call(doFetch, token, path, send) {
|
|
|
124
127
|
}
|
|
125
128
|
function refusal(answer, stage, subject) {
|
|
126
129
|
const codes = answer.body?.codes ?? [];
|
|
127
|
-
const
|
|
130
|
+
const messages = answer.body?.messages ?? [];
|
|
131
|
+
const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c)) || messages.some((m) => INVALID_TOKEN_MESSAGE.test(m));
|
|
128
132
|
if (stage === "zones" && refusedToken) return new DnsWriteError("token_invalid");
|
|
129
133
|
if (codes.some((c) => CONFLICT_CODES.has(c)))
|
|
130
134
|
return new DnsWriteError("conflict", { ...subject });
|
package/dist/index.cjs
CHANGED
|
@@ -606,6 +606,7 @@ function template(identity, serviceId, fallback, wording, records, hostRequired)
|
|
|
606
606
|
syncPubKeyDomain: identity.syncPubKeyDomain,
|
|
607
607
|
syncRedirectDomain: identity.syncRedirectDomain,
|
|
608
608
|
...hostRequired ? { hostRequired } : {},
|
|
609
|
+
...wording.multiInstance !== void 0 ? { multiInstance: wording.multiInstance } : {},
|
|
609
610
|
records
|
|
610
611
|
};
|
|
611
612
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -70,14 +70,17 @@ interface DomainConnectTemplate extends TemplateIdentity {
|
|
|
70
70
|
variableDescription?: string;
|
|
71
71
|
/** The subdomain is required: the template has no meaning at the name itself. */
|
|
72
72
|
hostRequired?: boolean;
|
|
73
|
+
/** Tell a stateful provider that a re-apply must not remove earlier applications. */
|
|
74
|
+
multiInstance?: boolean;
|
|
73
75
|
records: TemplateRecord[];
|
|
74
76
|
}
|
|
75
|
-
/** What every preset lets the app
|
|
77
|
+
/** What every preset lets the app choose. */
|
|
76
78
|
interface Wording {
|
|
77
79
|
serviceId?: string;
|
|
78
80
|
serviceName?: string;
|
|
79
81
|
description?: string;
|
|
80
82
|
version?: number;
|
|
83
|
+
multiInstance?: boolean;
|
|
81
84
|
}
|
|
82
85
|
/** The `<providerId>.<serviceId>.json` name the Templates repository files it under. */
|
|
83
86
|
declare function templateFileName(t: Pick<DomainConnectTemplate, "providerId" | "serviceId">): string;
|
package/dist/index.d.ts
CHANGED
|
@@ -70,14 +70,17 @@ interface DomainConnectTemplate extends TemplateIdentity {
|
|
|
70
70
|
variableDescription?: string;
|
|
71
71
|
/** The subdomain is required: the template has no meaning at the name itself. */
|
|
72
72
|
hostRequired?: boolean;
|
|
73
|
+
/** Tell a stateful provider that a re-apply must not remove earlier applications. */
|
|
74
|
+
multiInstance?: boolean;
|
|
73
75
|
records: TemplateRecord[];
|
|
74
76
|
}
|
|
75
|
-
/** What every preset lets the app
|
|
77
|
+
/** What every preset lets the app choose. */
|
|
76
78
|
interface Wording {
|
|
77
79
|
serviceId?: string;
|
|
78
80
|
serviceName?: string;
|
|
79
81
|
description?: string;
|
|
80
82
|
version?: number;
|
|
83
|
+
multiInstance?: boolean;
|
|
81
84
|
}
|
|
82
85
|
/** The `<providerId>.<serviceId>.json` name the Templates repository files it under. */
|
|
83
86
|
declare function templateFileName(t: Pick<DomainConnectTemplate, "providerId" | "serviceId">): string;
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,7 @@ function template(identity, serviceId, fallback, wording, records, hostRequired)
|
|
|
60
60
|
syncPubKeyDomain: identity.syncPubKeyDomain,
|
|
61
61
|
syncRedirectDomain: identity.syncRedirectDomain,
|
|
62
62
|
...hostRequired ? { hostRequired } : {},
|
|
63
|
+
...wording.multiInstance !== void 0 ? { multiInstance: wording.multiInstance } : {},
|
|
63
64
|
records
|
|
64
65
|
};
|
|
65
66
|
}
|
package/dist/server/index.cjs
CHANGED
|
@@ -627,7 +627,10 @@ var CONNECT_PROVIDERS = [
|
|
|
627
627
|
id: "cloudflare",
|
|
628
628
|
txt: "api.cloudflare.com/client/v4/dns/domainconnect",
|
|
629
629
|
api: "https://api.cloudflare.com/client/v4/dns/domainconnect",
|
|
630
|
-
syncHost: "dash.cloudflare.com"
|
|
630
|
+
syncHost: "dash.cloudflare.com",
|
|
631
|
+
// Cloudflare documents this flag as unsupported and ignored:
|
|
632
|
+
// https://developers.cloudflare.com/dns/reference/domain-connect/#template-definition
|
|
633
|
+
supportsMultiInstance: false
|
|
631
634
|
},
|
|
632
635
|
// `_domainconnect.nextjs.org` read 2026-10-29: a CNAME to `_domainconnect.vercel-dns.com`, whose
|
|
633
636
|
// TXT is `domainconnect.vercel.com`. The settings name another address for the API.
|
|
@@ -647,6 +650,9 @@ var CONNECT_PROVIDERS = [
|
|
|
647
650
|
// Settings read 2026-10-29 from domainconnect.api.godaddy.com (`urlSyncUX`
|
|
648
651
|
// https://dcc.godaddy.com/manage). The TXT value on a customer's zone is not read yet: if it
|
|
649
652
|
// differs, GoDaddy answers "not offered", which is the safe way to be wrong.
|
|
653
|
+
// GoDaddy told us on 2026-10-29 that it is "not currently accepting new templates for direct
|
|
654
|
+
// onboarding", with no timeline. It supports many templates already (9 of 12 third-party ones
|
|
655
|
+
// we probed), so the entry stays for them, but a template of yours answers "not offered" there.
|
|
650
656
|
{
|
|
651
657
|
id: "godaddy",
|
|
652
658
|
txt: "domainconnect.api.godaddy.com",
|
|
@@ -747,6 +753,7 @@ async function ask(io, args, deadline) {
|
|
|
747
753
|
const values = published.txts.map(normalizeTxt);
|
|
748
754
|
const provider = providers.find((entry) => values.includes(entry.txt));
|
|
749
755
|
if (!provider) return NOT_OFFERED;
|
|
756
|
+
if (args.requireMultiInstance && provider.supportsMultiInstance !== true) return NOT_OFFERED;
|
|
750
757
|
const settings = await get(`https://${provider.txt}/v2/${zone}/settings`);
|
|
751
758
|
if (!settings) return UNANSWERED;
|
|
752
759
|
if (settings.status !== 200) return transient(settings.status) ? UNANSWERED : NOT_OFFERED;
|
|
@@ -767,6 +774,15 @@ async function ask(io, args, deadline) {
|
|
|
767
774
|
);
|
|
768
775
|
if (!template) return UNANSWERED;
|
|
769
776
|
if (template.status !== 200) return transient(template.status) ? UNANSWERED : NOT_OFFERED;
|
|
777
|
+
if (args.requireMultiInstance) {
|
|
778
|
+
let templateBody;
|
|
779
|
+
try {
|
|
780
|
+
templateBody = JSON.parse(template.text);
|
|
781
|
+
} catch {
|
|
782
|
+
return NOT_OFFERED;
|
|
783
|
+
}
|
|
784
|
+
if (!isObject(templateBody) || templateBody.multiInstance !== true) return NOT_OFFERED;
|
|
785
|
+
}
|
|
770
786
|
return {
|
|
771
787
|
found: true,
|
|
772
788
|
id: provider.id,
|
|
@@ -858,23 +874,26 @@ var API = "https://api.cloudflare.com/client/v4";
|
|
|
858
874
|
var TIMEOUT_MS = 1e4;
|
|
859
875
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
860
876
|
var TOKEN_CODES = /* @__PURE__ */ new Set([1e3, 6003, 6111, 1e4]);
|
|
877
|
+
var INVALID_TOKEN_MESSAGE = /invalid access token/i;
|
|
861
878
|
var CONFLICT_CODES = /* @__PURE__ */ new Set([81053, 81054, 81057]);
|
|
862
879
|
var ID = /^[A-Za-z0-9]{1,64}$/;
|
|
863
880
|
var RECORD_TYPE = /^[A-Z0-9]{1,12}$/;
|
|
864
881
|
function readEnvelope(json) {
|
|
865
882
|
if (!isObject(json) || typeof json.success !== "boolean") return null;
|
|
866
883
|
const codes = [];
|
|
884
|
+
const messages = [];
|
|
867
885
|
if (Array.isArray(json.errors)) {
|
|
868
886
|
for (const error of json.errors) {
|
|
869
887
|
if (!isObject(error)) return null;
|
|
870
888
|
if (typeof error.code === "number") codes.push(error.code);
|
|
889
|
+
if (typeof error.message === "string") messages.push(error.message);
|
|
871
890
|
if (Array.isArray(error.error_chain)) {
|
|
872
891
|
for (const link of error.error_chain)
|
|
873
892
|
if (isObject(link) && typeof link.code === "number") codes.push(link.code);
|
|
874
893
|
}
|
|
875
894
|
}
|
|
876
895
|
}
|
|
877
|
-
return { success: json.success, codes, result: json.result };
|
|
896
|
+
return { success: json.success, codes, messages, result: json.result };
|
|
878
897
|
}
|
|
879
898
|
async function call(doFetch, token, path, send) {
|
|
880
899
|
const res = await fetchBounded(
|
|
@@ -901,7 +920,8 @@ async function call(doFetch, token, path, send) {
|
|
|
901
920
|
}
|
|
902
921
|
function refusal(answer, stage, subject) {
|
|
903
922
|
const codes = answer.body?.codes ?? [];
|
|
904
|
-
const
|
|
923
|
+
const messages = answer.body?.messages ?? [];
|
|
924
|
+
const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c)) || messages.some((m) => INVALID_TOKEN_MESSAGE.test(m));
|
|
905
925
|
if (stage === "zones" && refusedToken) return new DnsWriteError("token_invalid");
|
|
906
926
|
if (codes.some((c) => CONFLICT_CODES.has(c)))
|
|
907
927
|
return new DnsWriteError("conflict", { ...subject });
|
package/dist/server/index.d.cts
CHANGED
|
@@ -52,6 +52,8 @@ interface ConnectProvider {
|
|
|
52
52
|
api: string;
|
|
53
53
|
/** The one host the settings' `urlSyncUX` may name: the page we send the person's browser to. */
|
|
54
54
|
syncHost: string;
|
|
55
|
+
/** Verified support for separate template instances. Omitted means unverified. */
|
|
56
|
+
supportsMultiInstance?: boolean;
|
|
55
57
|
}
|
|
56
58
|
declare const CONNECT_PROVIDERS: readonly ConnectProvider[];
|
|
57
59
|
|
|
@@ -81,6 +83,10 @@ declare function discoverConnect(io: DiscoveryIo, args: {
|
|
|
81
83
|
providerId: string;
|
|
82
84
|
serviceId: string;
|
|
83
85
|
budgetMs: number;
|
|
86
|
+
/** Require verified provider support and template JSON with `multiInstance: true`.
|
|
87
|
+
* Callers that later remove one application through the async API must pass a
|
|
88
|
+
* stable instanceId for each independent service application. */
|
|
89
|
+
requireMultiInstance?: boolean;
|
|
84
90
|
providers?: readonly ConnectProvider[];
|
|
85
91
|
}): Promise<Discovery>;
|
|
86
92
|
|
package/dist/server/index.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ interface ConnectProvider {
|
|
|
52
52
|
api: string;
|
|
53
53
|
/** The one host the settings' `urlSyncUX` may name: the page we send the person's browser to. */
|
|
54
54
|
syncHost: string;
|
|
55
|
+
/** Verified support for separate template instances. Omitted means unverified. */
|
|
56
|
+
supportsMultiInstance?: boolean;
|
|
55
57
|
}
|
|
56
58
|
declare const CONNECT_PROVIDERS: readonly ConnectProvider[];
|
|
57
59
|
|
|
@@ -81,6 +83,10 @@ declare function discoverConnect(io: DiscoveryIo, args: {
|
|
|
81
83
|
providerId: string;
|
|
82
84
|
serviceId: string;
|
|
83
85
|
budgetMs: number;
|
|
86
|
+
/** Require verified provider support and template JSON with `multiInstance: true`.
|
|
87
|
+
* Callers that later remove one application through the async API must pass a
|
|
88
|
+
* stable instanceId for each independent service application. */
|
|
89
|
+
requireMultiInstance?: boolean;
|
|
84
90
|
providers?: readonly ConnectProvider[];
|
|
85
91
|
}): Promise<Discovery>;
|
|
86
92
|
|
package/dist/server/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
fetchBounded,
|
|
6
6
|
isObject,
|
|
7
7
|
writers
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-LLWYWMJ5.js";
|
|
9
9
|
import {
|
|
10
10
|
normalizeDnsName
|
|
11
11
|
} from "../chunk-P2O7INDB.js";
|
|
@@ -92,7 +92,10 @@ var CONNECT_PROVIDERS = [
|
|
|
92
92
|
id: "cloudflare",
|
|
93
93
|
txt: "api.cloudflare.com/client/v4/dns/domainconnect",
|
|
94
94
|
api: "https://api.cloudflare.com/client/v4/dns/domainconnect",
|
|
95
|
-
syncHost: "dash.cloudflare.com"
|
|
95
|
+
syncHost: "dash.cloudflare.com",
|
|
96
|
+
// Cloudflare documents this flag as unsupported and ignored:
|
|
97
|
+
// https://developers.cloudflare.com/dns/reference/domain-connect/#template-definition
|
|
98
|
+
supportsMultiInstance: false
|
|
96
99
|
},
|
|
97
100
|
// `_domainconnect.nextjs.org` read 2026-10-29: a CNAME to `_domainconnect.vercel-dns.com`, whose
|
|
98
101
|
// TXT is `domainconnect.vercel.com`. The settings name another address for the API.
|
|
@@ -112,6 +115,9 @@ var CONNECT_PROVIDERS = [
|
|
|
112
115
|
// Settings read 2026-10-29 from domainconnect.api.godaddy.com (`urlSyncUX`
|
|
113
116
|
// https://dcc.godaddy.com/manage). The TXT value on a customer's zone is not read yet: if it
|
|
114
117
|
// differs, GoDaddy answers "not offered", which is the safe way to be wrong.
|
|
118
|
+
// GoDaddy told us on 2026-10-29 that it is "not currently accepting new templates for direct
|
|
119
|
+
// onboarding", with no timeline. It supports many templates already (9 of 12 third-party ones
|
|
120
|
+
// we probed), so the entry stays for them, but a template of yours answers "not offered" there.
|
|
115
121
|
{
|
|
116
122
|
id: "godaddy",
|
|
117
123
|
txt: "domainconnect.api.godaddy.com",
|
|
@@ -183,6 +189,7 @@ async function ask(io, args, deadline) {
|
|
|
183
189
|
const values = published.txts.map(normalizeTxt);
|
|
184
190
|
const provider = providers.find((entry) => values.includes(entry.txt));
|
|
185
191
|
if (!provider) return NOT_OFFERED;
|
|
192
|
+
if (args.requireMultiInstance && provider.supportsMultiInstance !== true) return NOT_OFFERED;
|
|
186
193
|
const settings = await get(`https://${provider.txt}/v2/${zone}/settings`);
|
|
187
194
|
if (!settings) return UNANSWERED;
|
|
188
195
|
if (settings.status !== 200) return transient(settings.status) ? UNANSWERED : NOT_OFFERED;
|
|
@@ -203,6 +210,15 @@ async function ask(io, args, deadline) {
|
|
|
203
210
|
);
|
|
204
211
|
if (!template) return UNANSWERED;
|
|
205
212
|
if (template.status !== 200) return transient(template.status) ? UNANSWERED : NOT_OFFERED;
|
|
213
|
+
if (args.requireMultiInstance) {
|
|
214
|
+
let templateBody;
|
|
215
|
+
try {
|
|
216
|
+
templateBody = JSON.parse(template.text);
|
|
217
|
+
} catch {
|
|
218
|
+
return NOT_OFFERED;
|
|
219
|
+
}
|
|
220
|
+
if (!isObject(templateBody) || templateBody.multiInstance !== true) return NOT_OFFERED;
|
|
221
|
+
}
|
|
206
222
|
return {
|
|
207
223
|
found: true,
|
|
208
224
|
id: provider.id,
|
package/dist/writers/index.cjs
CHANGED
|
@@ -113,23 +113,26 @@ var API = "https://api.cloudflare.com/client/v4";
|
|
|
113
113
|
var TIMEOUT_MS = 1e4;
|
|
114
114
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
115
115
|
var TOKEN_CODES = /* @__PURE__ */ new Set([1e3, 6003, 6111, 1e4]);
|
|
116
|
+
var INVALID_TOKEN_MESSAGE = /invalid access token/i;
|
|
116
117
|
var CONFLICT_CODES = /* @__PURE__ */ new Set([81053, 81054, 81057]);
|
|
117
118
|
var ID = /^[A-Za-z0-9]{1,64}$/;
|
|
118
119
|
var RECORD_TYPE = /^[A-Z0-9]{1,12}$/;
|
|
119
120
|
function readEnvelope(json) {
|
|
120
121
|
if (!isObject(json) || typeof json.success !== "boolean") return null;
|
|
121
122
|
const codes = [];
|
|
123
|
+
const messages = [];
|
|
122
124
|
if (Array.isArray(json.errors)) {
|
|
123
125
|
for (const error of json.errors) {
|
|
124
126
|
if (!isObject(error)) return null;
|
|
125
127
|
if (typeof error.code === "number") codes.push(error.code);
|
|
128
|
+
if (typeof error.message === "string") messages.push(error.message);
|
|
126
129
|
if (Array.isArray(error.error_chain)) {
|
|
127
130
|
for (const link of error.error_chain)
|
|
128
131
|
if (isObject(link) && typeof link.code === "number") codes.push(link.code);
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
|
-
return { success: json.success, codes, result: json.result };
|
|
135
|
+
return { success: json.success, codes, messages, result: json.result };
|
|
133
136
|
}
|
|
134
137
|
async function call(doFetch, token, path, send) {
|
|
135
138
|
const res = await fetchBounded(
|
|
@@ -156,7 +159,8 @@ async function call(doFetch, token, path, send) {
|
|
|
156
159
|
}
|
|
157
160
|
function refusal(answer, stage, subject) {
|
|
158
161
|
const codes = answer.body?.codes ?? [];
|
|
159
|
-
const
|
|
162
|
+
const messages = answer.body?.messages ?? [];
|
|
163
|
+
const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c)) || messages.some((m) => INVALID_TOKEN_MESSAGE.test(m));
|
|
160
164
|
if (stage === "zones" && refusedToken) return new DnsWriteError("token_invalid");
|
|
161
165
|
if (codes.some((c) => CONFLICT_CODES.has(c)))
|
|
162
166
|
return new DnsWriteError("conflict", { ...subject });
|
package/dist/writers/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/one-click-dns",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"packageManager": "bun@1.4.2",
|
|
5
5
|
"description": "Put DNS records in a customer's zone: find their DNS host, send them to a signed Domain Connect link, write with an API token, or show the steps for that host",
|
|
6
6
|
"type": "module",
|
|
@@ -47,9 +47,10 @@
|
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "tsup src/index.ts src/server/index.ts src/writers/index.ts --format cjs,esm --dts --clean",
|
|
49
49
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"lint": "eslint src",
|
|
50
|
+
"lint": "eslint src scripts",
|
|
51
51
|
"format": "prettier --write .",
|
|
52
52
|
"test": "bun test",
|
|
53
|
+
"live": "bun scripts/live-check.ts",
|
|
53
54
|
"prepublishOnly": "bun run lint && bun run typecheck && bun run build"
|
|
54
55
|
},
|
|
55
56
|
"keywords": [
|