@gusnips/one-click-dns 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # @gusnips/one-click-dns
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Put DNS records in a customer's zone. The package finds where their DNS lives, then gives you the best way to add the record: a signed Domain Connect link, a pasted API token, or the steps for that host.
4
+
5
+ ```bash
6
+ npm install @gusnips/one-click-dns
7
+ # or
8
+ bun add @gusnips/one-click-dns
9
+ ```
10
+
11
+ It has three entry points:
12
+
13
+ - `@gusnips/one-click-dns`: runs in the browser. The host list, the record spelling and the template presets.
14
+ - `@gusnips/one-click-dns/server`: reads DNS, builds signed links. Node or Bun.
15
+ - `@gusnips/one-click-dns/writers`: writes a record with an API token. Server only.
16
+
17
+ Keep keys and tokens on the server. Nothing in the browser entry needs them.
18
+
19
+ ## First call
20
+
21
+ ```ts
22
+ import { planConnection, systemDnsLookups } from "@gusnips/one-click-dns/server";
23
+
24
+ const plan = await planConnection({ hostname: "help.acme.com" }, systemDnsLookups(2000));
25
+
26
+ console.log(plan.reading?.hosts); // ["cloudflare"]
27
+ console.log(plan.methods.map((method) => method.kind)); // ["api-token", "manual"]
28
+ ```
29
+
30
+ `methods` lists the ways to connect, best first:
31
+
32
+ | `kind` | What it means |
33
+ | ---------------- | ----------------------------------------------------------------------------------------------------- |
34
+ | `domain-connect` | One click. You send the person to `url`, their DNS host asks them to approve, and the records appear. |
35
+ | `api-token` | The person pastes a token, and your server writes the record. |
36
+ | `manual` | The person copies the records by hand. The host's name is in `hosts`. Always the last one. |
37
+
38
+ `plan.reading` is `null` when DNS did not answer. That is not the same as "host unknown", so do not show it as one.
39
+
40
+ ## Add a record with a token
41
+
42
+ ```ts
43
+ import { cloudflare } from "@gusnips/one-click-dns/writers";
44
+
45
+ const outcome = await cloudflare.addRecord({
46
+ token, // the person's Cloudflare API token
47
+ record: { type: "CNAME", name: "help.acme.com", value: "abc123.domains.example.app" },
48
+ });
49
+ // "created", "unchanged" or "unproxied"
50
+ ```
51
+
52
+ The token is used for this call only. It is never stored, logged or put in an error. A record the person already has is never overwritten: you get a `conflict` instead.
53
+
54
+ When the write fails, it throws a `DnsWriteError` with a `code`. Word each code in your own languages:
55
+
56
+ | `code` | What to tell the person |
57
+ | ------------------- | ------------------------------------------------------------------------------------ |
58
+ | `token_invalid` | The host did not accept the token. Paste it again. |
59
+ | `zone_read_missing` | The token cannot list domains. Give it that permission. |
60
+ | `dns_edit_missing` | The token cannot change DNS records. Give it that permission. |
61
+ | `zone_not_found` | The token's account does not hold this domain. |
62
+ | `conflict` | The name already has another record (`params.type`). They decide what to do with it. |
63
+ | `unavailable` | The host did not answer. Try again. |
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:
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 |
73
+
74
+ `writers` maps a host id to its writer, so `writers[host]` is `undefined` for a host with no writer.
75
+
76
+ ## One-click links (Domain Connect)
77
+
78
+ 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.
79
+
80
+ ```ts
81
+ const plan = await planConnection(
82
+ {
83
+ hostname: "help.acme.com",
84
+ connect: {
85
+ providerId: "example.com", // your id in the Templates repository
86
+ keyId: "_dck1", // the TXT record name that holds your public key
87
+ serviceId: "custom-domain", // your template
88
+ privateKeyPem,
89
+ params: (zone) => ({
90
+ domain: zone,
91
+ host: "help",
92
+ target: "abc123",
93
+ redirect_uri: "https://app.example.com/done",
94
+ }),
95
+ },
96
+ },
97
+ systemDnsLookups(2000),
98
+ );
99
+ ```
100
+
101
+ When the host has your template, `plan.methods` starts with a `domain-connect` method. Sending the person back to `redirect_uri` proves nothing. Check the DNS yourself before you mark the domain done.
102
+
103
+ 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
+
105
+ ## Templates
106
+
107
+ A template is a small JSON file in the public [Domain Connect Templates](https://github.com/Domain-Connect/Templates) repository. It lists the records you may ask a host to add. The presets build one that follows the repository's rules:
108
+
109
+ ```ts
110
+ import { cnameTemplate, templateFileName } from "@gusnips/one-click-dns";
111
+
112
+ const template = cnameTemplate(
113
+ {
114
+ providerId: "example.com",
115
+ providerName: "Example",
116
+ logoUrl: "https://example.com/logo.svg",
117
+ syncPubKeyDomain: "example.com",
118
+ syncRedirectDomain: "app.example.com",
119
+ },
120
+ { pointsTo: "%target%.domains.example.com" },
121
+ );
122
+
123
+ console.log(templateFileName(template)); // "example.com.custom-domain.json"
124
+ ```
125
+
126
+ There are presets for a CNAME, DMARC, SPF, DKIM, MX and a verification TXT.
127
+
128
+ 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.
129
+
130
+ 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.
131
+
132
+ ## Steps for the host
133
+
134
+ `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:
135
+
136
+ ```ts
137
+ import { dnsRecordRow } from "@gusnips/one-click-dns";
138
+
139
+ dnsRecordRow("route53", "acme.com", { type: "TXT", name: "acme.com", value: "v=spf1 ~all" });
140
+ // { type: "TXT", name: "", value: "\"v=spf1 ~all\"", atRoot: true, rootUnsure: false }
141
+ ```
142
+
143
+ A name server pattern is in the list only when the host documents it or it was seen in DNS. A label is quoted only when the host's own page shows it.
144
+
145
+ ## Checks
146
+
147
+ ```bash
148
+ bun run lint
149
+ bun run typecheck
150
+ bun run build
151
+ bun test src/path/to/file.test.ts
152
+ ```
@@ -0,0 +1,5 @@
1
+ /** What the package needs from `fetch`: a function from a URL and options to a response. The
2
+ * global `fetch` is one, and so is a test's fake, with no cast. */
3
+ type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
4
+
5
+ export type { FetchFn as F };
@@ -0,0 +1,5 @@
1
+ /** What the package needs from `fetch`: a function from a URL and options to a response. The
2
+ * global `fetch` is one, and so is a test's fake, with no cast. */
3
+ type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
4
+
5
+ export type { FetchFn as F };