@gusnips/one-click-dns 0.1.4 → 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 CHANGED
@@ -159,6 +159,39 @@ A template belongs to the company that files it. It names your key domain, and t
159
159
 
160
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.
161
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
+
162
195
  ## Steps for the host
163
196
 
164
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:
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 word itself. */
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 word itself. */
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
  }
@@ -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.
@@ -750,6 +753,7 @@ async function ask(io, args, deadline) {
750
753
  const values = published.txts.map(normalizeTxt);
751
754
  const provider = providers.find((entry) => values.includes(entry.txt));
752
755
  if (!provider) return NOT_OFFERED;
756
+ if (args.requireMultiInstance && provider.supportsMultiInstance !== true) return NOT_OFFERED;
753
757
  const settings = await get(`https://${provider.txt}/v2/${zone}/settings`);
754
758
  if (!settings) return UNANSWERED;
755
759
  if (settings.status !== 200) return transient(settings.status) ? UNANSWERED : NOT_OFFERED;
@@ -770,6 +774,15 @@ async function ask(io, args, deadline) {
770
774
  );
771
775
  if (!template) return UNANSWERED;
772
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
+ }
773
786
  return {
774
787
  found: true,
775
788
  id: provider.id,
@@ -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
 
@@ -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
 
@@ -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.
@@ -186,6 +189,7 @@ async function ask(io, args, deadline) {
186
189
  const values = published.txts.map(normalizeTxt);
187
190
  const provider = providers.find((entry) => values.includes(entry.txt));
188
191
  if (!provider) return NOT_OFFERED;
192
+ if (args.requireMultiInstance && provider.supportsMultiInstance !== true) return NOT_OFFERED;
189
193
  const settings = await get(`https://${provider.txt}/v2/${zone}/settings`);
190
194
  if (!settings) return UNANSWERED;
191
195
  if (settings.status !== 200) return transient(settings.status) ? UNANSWERED : NOT_OFFERED;
@@ -206,6 +210,15 @@ async function ask(io, args, deadline) {
206
210
  );
207
211
  if (!template) return UNANSWERED;
208
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
+ }
209
222
  return {
210
223
  found: true,
211
224
  id: provider.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/one-click-dns",
3
- "version": "0.1.4",
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",