@gusnips/one-click-dns 0.1.2 → 0.1.4

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
@@ -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. Every test uses a fake network, so none of them has run against a real account here:
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` | Lifted from featury's live help-center code |
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 | Written from the host's API spec. Never run against a real account |
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:
@@ -141,6 +153,8 @@ console.log(templateFileName(template)); // "example.com.custom-domain.json"
141
153
 
142
154
  There are presets for a CNAME, DMARC, SPF, DKIM, MX and a verification TXT.
143
155
 
156
+ Two of them change how a domain's email works. The MX preset replaces the MX records the domain has, so its mail goes somewhere else. The SPF preset adds a sender to the domain's SPF record. Ask the person for consent in your own screen before you offer either link, and say what changes in the template's `description`, which the DNS host shows when they approve.
157
+
144
158
  A template belongs to the company that files it. It names your key domain, and the host checks every link against the key published there. So you cannot use someone else's template, and nobody can use yours. The customer's domain is not in the template: it comes with each link, so one template serves every customer.
145
159
 
146
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.
@@ -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 refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c));
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
@@ -663,21 +663,24 @@ function spfTemplate(identity, options = {}) {
663
663
  );
664
664
  }
665
665
  function dkimTemplate(identity, options = {}) {
666
- const keys = options.keys ?? 2;
666
+ const { selectors } = options;
667
+ const keys = selectors?.length ?? options.keys ?? 2;
667
668
  const numbers = Array.from({ length: keys }, (_, index) => index + 1);
669
+ const hostOf = (n) => selectors ? `${selectors[n - 1]}._domainkey` : `%selector${n}%._domainkey`;
670
+ const describe = (n) => selectors ? `%target${n}%: where ${selectors[n - 1]}._domainkey points` : `%selector${n}%: the key name, ${n} of ${keys}; %target${n}%: where it points`;
668
671
  return template(
669
672
  identity,
670
673
  "dkim",
671
674
  {
672
675
  serviceName: "Sign your email with our key",
673
676
  description: "Adds the CNAME records that let us sign email sent from your domain.",
674
- variableDescription: numbers.map((n) => `%selector${n}%: the key name, ${n} of ${keys}; %target${n}%: where it points`).join("; ")
677
+ variableDescription: options.variableDescription ?? numbers.map(describe).join("; ")
675
678
  },
676
679
  options,
677
680
  numbers.map((n) => ({
678
681
  type: "CNAME",
679
- host: `%selector${n}%._domainkey`,
680
- pointsTo: `%target${n}%`,
682
+ host: hostOf(n),
683
+ pointsTo: options.pointsTo?.(n) ?? `%target${n}%`,
681
684
  ttl: TTL
682
685
  }))
683
686
  );
package/dist/index.d.cts CHANGED
@@ -99,12 +99,24 @@ declare function dmarcTemplate(identity: TemplateIdentity, options?: Wording & {
99
99
  /** Adds one `include:` to the domain's SPF record. `SPFM` merges it into the one record the
100
100
  * name may have; a TXT would make a second one and break SPF. The link carries `%include%`. */
101
101
  declare function spfTemplate(identity: TemplateIdentity, options?: Wording): DomainConnectTemplate;
102
- /** DKIM as CNAMEs, for services that publish the key on their side. The link carries
103
- * `%selector1%` and `%target1%`, then the same pair numbered up to `keys`. */
102
+ /**
103
+ * DKIM as CNAMEs, for services that publish the key on their side.
104
+ *
105
+ * By default the link carries `%selector1%` and `%target1%`, then the same pair numbered up to
106
+ * `keys`. When your key names never change, fix them: `selectors: ["cd1", "cd2"]` writes
107
+ * `cd1._domainkey` and `cd2._domainkey` into the template, which the repository prefers to a
108
+ * variable. `pointsTo` then names where each CNAME goes (`(n) => "%dkim" + n + "%.example.net"`),
109
+ * and `variableDescription` says what its variables mean.
110
+ */
104
111
  declare function dkimTemplate(identity: TemplateIdentity, options?: Wording & {
105
112
  keys?: number;
113
+ selectors?: readonly string[];
114
+ pointsTo?: (n: number) => string;
115
+ variableDescription?: string;
106
116
  }): DomainConnectTemplate;
107
- /** The MX record that sends the domain's mail to you. */
117
+ /** The MX record that sends the domain's mail to you. It changes where the domain's email is
118
+ * delivered and replaces the MX records it has, so ask the person for consent before you offer
119
+ * the link; say so in `description` too. */
108
120
  declare function mxTemplate(identity: TemplateIdentity, options: Wording & {
109
121
  pointsTo: string;
110
122
  priority?: number;
package/dist/index.d.ts CHANGED
@@ -99,12 +99,24 @@ declare function dmarcTemplate(identity: TemplateIdentity, options?: Wording & {
99
99
  /** Adds one `include:` to the domain's SPF record. `SPFM` merges it into the one record the
100
100
  * name may have; a TXT would make a second one and break SPF. The link carries `%include%`. */
101
101
  declare function spfTemplate(identity: TemplateIdentity, options?: Wording): DomainConnectTemplate;
102
- /** DKIM as CNAMEs, for services that publish the key on their side. The link carries
103
- * `%selector1%` and `%target1%`, then the same pair numbered up to `keys`. */
102
+ /**
103
+ * DKIM as CNAMEs, for services that publish the key on their side.
104
+ *
105
+ * By default the link carries `%selector1%` and `%target1%`, then the same pair numbered up to
106
+ * `keys`. When your key names never change, fix them: `selectors: ["cd1", "cd2"]` writes
107
+ * `cd1._domainkey` and `cd2._domainkey` into the template, which the repository prefers to a
108
+ * variable. `pointsTo` then names where each CNAME goes (`(n) => "%dkim" + n + "%.example.net"`),
109
+ * and `variableDescription` says what its variables mean.
110
+ */
104
111
  declare function dkimTemplate(identity: TemplateIdentity, options?: Wording & {
105
112
  keys?: number;
113
+ selectors?: readonly string[];
114
+ pointsTo?: (n: number) => string;
115
+ variableDescription?: string;
106
116
  }): DomainConnectTemplate;
107
- /** The MX record that sends the domain's mail to you. */
117
+ /** The MX record that sends the domain's mail to you. It changes where the domain's email is
118
+ * delivered and replaces the MX records it has, so ask the person for consent before you offer
119
+ * the link; say so in `description` too. */
108
120
  declare function mxTemplate(identity: TemplateIdentity, options: Wording & {
109
121
  pointsTo: string;
110
122
  priority?: number;
package/dist/index.js CHANGED
@@ -117,21 +117,24 @@ function spfTemplate(identity, options = {}) {
117
117
  );
118
118
  }
119
119
  function dkimTemplate(identity, options = {}) {
120
- const keys = options.keys ?? 2;
120
+ const { selectors } = options;
121
+ const keys = selectors?.length ?? options.keys ?? 2;
121
122
  const numbers = Array.from({ length: keys }, (_, index) => index + 1);
123
+ const hostOf = (n) => selectors ? `${selectors[n - 1]}._domainkey` : `%selector${n}%._domainkey`;
124
+ const describe = (n) => selectors ? `%target${n}%: where ${selectors[n - 1]}._domainkey points` : `%selector${n}%: the key name, ${n} of ${keys}; %target${n}%: where it points`;
122
125
  return template(
123
126
  identity,
124
127
  "dkim",
125
128
  {
126
129
  serviceName: "Sign your email with our key",
127
130
  description: "Adds the CNAME records that let us sign email sent from your domain.",
128
- variableDescription: numbers.map((n) => `%selector${n}%: the key name, ${n} of ${keys}; %target${n}%: where it points`).join("; ")
131
+ variableDescription: options.variableDescription ?? numbers.map(describe).join("; ")
129
132
  },
130
133
  options,
131
134
  numbers.map((n) => ({
132
135
  type: "CNAME",
133
- host: `%selector${n}%._domainkey`,
134
- pointsTo: `%target${n}%`,
136
+ host: hostOf(n),
137
+ pointsTo: options.pointsTo?.(n) ?? `%target${n}%`,
135
138
  ttl: TTL
136
139
  }))
137
140
  );
@@ -647,6 +647,9 @@ var CONNECT_PROVIDERS = [
647
647
  // Settings read 2026-10-29 from domainconnect.api.godaddy.com (`urlSyncUX`
648
648
  // https://dcc.godaddy.com/manage). The TXT value on a customer's zone is not read yet: if it
649
649
  // differs, GoDaddy answers "not offered", which is the safe way to be wrong.
650
+ // GoDaddy told us on 2026-10-29 that it is "not currently accepting new templates for direct
651
+ // onboarding", with no timeline. It supports many templates already (9 of 12 third-party ones
652
+ // we probed), so the entry stays for them, but a template of yours answers "not offered" there.
650
653
  {
651
654
  id: "godaddy",
652
655
  txt: "domainconnect.api.godaddy.com",
@@ -858,23 +861,26 @@ var API = "https://api.cloudflare.com/client/v4";
858
861
  var TIMEOUT_MS = 1e4;
859
862
  var MAX_BODY_BYTES = 256 * 1024;
860
863
  var TOKEN_CODES = /* @__PURE__ */ new Set([1e3, 6003, 6111, 1e4]);
864
+ var INVALID_TOKEN_MESSAGE = /invalid access token/i;
861
865
  var CONFLICT_CODES = /* @__PURE__ */ new Set([81053, 81054, 81057]);
862
866
  var ID = /^[A-Za-z0-9]{1,64}$/;
863
867
  var RECORD_TYPE = /^[A-Z0-9]{1,12}$/;
864
868
  function readEnvelope(json) {
865
869
  if (!isObject(json) || typeof json.success !== "boolean") return null;
866
870
  const codes = [];
871
+ const messages = [];
867
872
  if (Array.isArray(json.errors)) {
868
873
  for (const error of json.errors) {
869
874
  if (!isObject(error)) return null;
870
875
  if (typeof error.code === "number") codes.push(error.code);
876
+ if (typeof error.message === "string") messages.push(error.message);
871
877
  if (Array.isArray(error.error_chain)) {
872
878
  for (const link of error.error_chain)
873
879
  if (isObject(link) && typeof link.code === "number") codes.push(link.code);
874
880
  }
875
881
  }
876
882
  }
877
- return { success: json.success, codes, result: json.result };
883
+ return { success: json.success, codes, messages, result: json.result };
878
884
  }
879
885
  async function call(doFetch, token, path, send) {
880
886
  const res = await fetchBounded(
@@ -901,7 +907,8 @@ async function call(doFetch, token, path, send) {
901
907
  }
902
908
  function refusal(answer, stage, subject) {
903
909
  const codes = answer.body?.codes ?? [];
904
- const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c));
910
+ const messages = answer.body?.messages ?? [];
911
+ const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c)) || messages.some((m) => INVALID_TOKEN_MESSAGE.test(m));
905
912
  if (stage === "zones" && refusedToken) return new DnsWriteError("token_invalid");
906
913
  if (codes.some((c) => CONFLICT_CODES.has(c)))
907
914
  return new DnsWriteError("conflict", { ...subject });
@@ -5,7 +5,7 @@ import {
5
5
  fetchBounded,
6
6
  isObject,
7
7
  writers
8
- } from "../chunk-4XNRV7QR.js";
8
+ } from "../chunk-LLWYWMJ5.js";
9
9
  import {
10
10
  normalizeDnsName
11
11
  } from "../chunk-P2O7INDB.js";
@@ -112,6 +112,9 @@ var CONNECT_PROVIDERS = [
112
112
  // Settings read 2026-10-29 from domainconnect.api.godaddy.com (`urlSyncUX`
113
113
  // https://dcc.godaddy.com/manage). The TXT value on a customer's zone is not read yet: if it
114
114
  // differs, GoDaddy answers "not offered", which is the safe way to be wrong.
115
+ // GoDaddy told us on 2026-10-29 that it is "not currently accepting new templates for direct
116
+ // onboarding", with no timeline. It supports many templates already (9 of 12 third-party ones
117
+ // we probed), so the entry stays for them, but a template of yours answers "not offered" there.
115
118
  {
116
119
  id: "godaddy",
117
120
  txt: "domainconnect.api.godaddy.com",
@@ -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 refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c));
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 });
@@ -5,7 +5,7 @@ import {
5
5
  namecom,
6
6
  porkbun,
7
7
  writers
8
- } from "../chunk-4XNRV7QR.js";
8
+ } from "../chunk-LLWYWMJ5.js";
9
9
  import "../chunk-P2O7INDB.js";
10
10
  export {
11
11
  DnsWriteError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/one-click-dns",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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": [