ninepm 0.72.0 → 0.79.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 CHANGED
@@ -62,6 +62,8 @@ Deploy a static directory:
62
62
  9pm deploy . --name my-app --slug my-app
63
63
  ```
64
64
 
65
+ Deploying again under the same slug replaces the live app. To try a change without touching it, deploy under a different slug, which creates a separate app at its own address. On Free, your one app slot can't also hold a separate preview app. Deploying to your existing app updates its live version. Practice mode rehearses the current app's workflow. Testing a changed version separately requires another app slot: a plan with more apps (Hobby allows 3).
66
+
65
67
  ### How long an app's address stays the same
66
68
 
67
69
  Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9pm.ai`, and the suffix comes from the app's internal id, not from the slug:
@@ -223,16 +225,47 @@ cannot be compared from outside, so that row follows the platform's own routing
223
225
  9pm domains wait my-app www.example.com
224
226
  ```
225
227
 
226
- `wait` exits `0` once the domain is live, `10` if it timed out while a record is missing or could
227
- not be checked, `11` if it timed out with every record checked and found (the platform is still
228
- finishing; wait again), `20` if the domain failed or expired, and `1` on a usage, sign-in or API
229
- error. `9pm domains status <app> <hostname> --json` prints `{hostname, app, state, routing, tls,
230
- provider, nameservers, records, error, expiresAt, checkedAt}`, where `state` is `live`,
228
+ `wait` exits `0` once the domain is live, `10` if it timed out while you have something to do (a
229
+ record is missing or could not be checked, or a CAA record blocks the certificate even though every
230
+ record is found), `11` if it timed out with every record checked and found and nothing left for you
231
+ (the platform is still finishing; wait again), `20` if the domain failed or expired, and `1` on a
232
+ usage, sign-in or API error. Since 0.74.0 a CAA block exits `10`; earlier versions exited `11`. Since 0.77.0 a bare domain whose DNS host cannot point a root
233
+ domain at a hostname (GoDaddy, Route 53, IONOS, Wix) makes `wait` stop at once with `10`: its `next`
234
+ is `use_www` with the `www` hostname to add instead, since no record at the root can finish it.
235
+ `9pm domains status <app> <hostname> --json` prints `{hostname, app, state, reason, next, routing,
236
+ tls, provider, providerId, nameservers, zone, records, advisories, error, expiresAt, checkedAt}`, where `provider` is
237
+ the DNS host (where the nameservers point, not necessarily where the domain was bought), `reason` says why
238
+ (`reason.providerMessage` carries the provider's own words when it reported any, with `source`
239
+ saying whether they are the certificate authority's),
240
+ `next` says who acts next and what to do, and `state` is `live`,
231
241
  `waiting_for_dns`, `dns_unverified` (routing is active but the records could not be checked),
232
242
  `issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
233
- say, and its `check` says why (`not_checked`, or `apex_addresses`). `list`, `status` and `wait`
243
+ say, and its `check` says why (`not_checked`, or `apex_addresses`). A record whose value was found
244
+ at the name with the domain typed twice (`_acme-challenge.www.example.com.example.com`) has
245
+ `check: "doubled"` and `doubledAt`: enter only the short name shown. Since 0.79.0, against a server
246
+ that sends them: a record answered by a wildcard record of the DNS host (`*.example.com`) rather
247
+ than one of its own has `check: "wildcard"` and `wildcard`, and the row says to add a record at that
248
+ name (the wildcard can stay; a TXT value is always added beside the values already there);
249
+ `resolvers` lists what each public resolver answered when two were asked, and when one shows the
250
+ expected value and another does not, the record is `check: "resolvers_disagree"` with `ok: null`:
251
+ neither cache proves which answer is current, so confirm the record at your DNS host and check again.
252
+ A record that does not match carries `ttlSeconds`, how long that answer may stay cached. A root
253
+ answering with addresses while routing is pending asks you to confirm the ALIAS or ANAME target,
254
+ since a parking page answers with addresses too. With every record found, the text names the
255
+ platform's next automatic check from `next.nextCheckAt`. `advisories` lists hints that
256
+ the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
257
+ Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` means leave the Host
258
+ field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
259
+ `typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
260
+ the text output then suggests connecting `www` instead. `list`, `status` and `wait`
234
261
  only read; `add`, `check`, `restart` and `remove` need permission to configure the app.
235
262
 
263
+ Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
264
+ https://<domain>` for a live one, and for any other its state and the `9pm domains status`
265
+ command to run. If the domains cannot be read within a few seconds, the deploy says so in one
266
+ line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
267
+ it removed.
268
+
236
269
  ## Commands
237
270
 
238
271
  ```sh
@@ -259,7 +292,7 @@ only read; `add`, `check`, `restart` and `remove` need permission to configure t
259
292
  9pm domains status <app> <hostname> [--json]
260
293
  9pm domains check <app> <hostname> [--json]
261
294
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
262
- 9pm domains restart <app> <hostname>
295
+ 9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
263
296
  9pm domains remove <app> <hostname> --confirm <hostname>
264
297
  9pm access show <site>
265
298
  9pm access public <site> [--switch-mode]
@@ -5,3 +5,11 @@ export declare function guessZoneApex(hostname: string): string;
5
5
  * `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
6
6
  */
7
7
  export declare function relativeRecordName(recordHost: string, apex: string): string;
8
+ /**
9
+ * The zone a hostname's records go in: the zone the DNS check found by walking up to the first
10
+ * name that answers NS, when it has one, otherwise the guess. The found zone is used only when
11
+ * the hostname is inside it and it is no shorter than the guess: a shorter one (`co.uk`) means
12
+ * the walk climbed past a domain that does not exist, which says nothing about where its
13
+ * records would go.
14
+ */
15
+ export declare function zoneApexFor(hostname: string, checkedZone?: unknown): string;
package/dist/dns-host.js CHANGED
@@ -25,3 +25,19 @@ export function relativeRecordName(recordHost, apex) {
25
25
  return "@";
26
26
  return host.endsWith(`.${apex}`) ? host.slice(0, -(apex.length + 1)) : host;
27
27
  }
28
+ /**
29
+ * The zone a hostname's records go in: the zone the DNS check found by walking up to the first
30
+ * name that answers NS, when it has one, otherwise the guess. The found zone is used only when
31
+ * the hostname is inside it and it is no shorter than the guess: a shorter one (`co.uk`) means
32
+ * the walk climbed past a domain that does not exist, which says nothing about where its
33
+ * records would go.
34
+ */
35
+ export function zoneApexFor(hostname, checkedZone) {
36
+ const guess = guessZoneApex(hostname);
37
+ if (typeof checkedZone !== "string" || checkedZone === "")
38
+ return guess;
39
+ const host = hostname.toLowerCase().replace(/\.$/, "");
40
+ const zone = checkedZone.toLowerCase().replace(/\.$/, "");
41
+ const inside = host === zone || host.endsWith(`.${zone}`);
42
+ return inside && zone.split(".").length >= guess.split(".").length ? zone : guess;
43
+ }
@@ -0,0 +1,91 @@
1
+ export declare const DNS_PROVIDER_SOURCE = "registrars-and-dns-automation.md, 2026-10-09 (Route 53 rechecked 2026-10-13)";
2
+ /** What can stand in for a root-domain record when the host cannot point the root at a hostname. */
3
+ export type ApexFallback =
4
+ /** Connect www.<zone> to the app instead: an ordinary CNAME, always possible. */
5
+ {
6
+ kind: "use_www";
7
+ }
8
+ /** The host's own forwarding of the root to www. Unverified: mentioned as a possibility, never as a step. */
9
+ | {
10
+ kind: "provider_forwarding";
11
+ verified: boolean;
12
+ https: "yes" | "unverified";
13
+ }
14
+ /** Route 53 only: an S3 redirect bucket behind CloudFront, aliased at the root. AWS-documented, not tested by us. */
15
+ | {
16
+ kind: "aws_redirect";
17
+ }
18
+ /** Move DNS hosting to a host that offers ALIAS or flattening, copying every existing record first. */
19
+ | {
20
+ kind: "move_dns_host";
21
+ };
22
+ export type ApexSupport = {
23
+ support: "alias";
24
+ typeLabel: "ALIAS" | "ANAME";
25
+ hostField: "blank" | "@";
26
+ } | {
27
+ support: "cname_flattening";
28
+ typeLabel: "CNAME";
29
+ hostField: "@";
30
+ }
31
+ /**
32
+ * `fallbacks` is ordered and always starts with use_www. `hostField` is the root's Host
33
+ * convention where the source states one, used only when routing is already active and the
34
+ * root row is shown after all; absent means `@`.
35
+ */
36
+ | {
37
+ support: "none";
38
+ fallbacks: ApexFallback[];
39
+ hostField?: "blank";
40
+ };
41
+ /** A note shown under the "your DNS is at" line. Only verified notes are rendered. */
42
+ export interface DnsProviderNote {
43
+ id: string;
44
+ verified: boolean;
45
+ }
46
+ export interface DnsProviderProfile {
47
+ apex: ApexSupport;
48
+ /**
49
+ * Whether the editor appends the zone to the name typed. True: the full name is never
50
+ * offered, since typing it creates `www.example.com.example.com`. "unknown" keeps showing it.
51
+ */
52
+ appendsZone: true | false | "unknown";
53
+ /** The editor makes a hostname value relative unless it ends in a dot (Gandi). */
54
+ valueNeedsTrailingDot?: true;
55
+ notes: DnsProviderNote[];
56
+ source: string;
57
+ }
58
+ export interface DnsProvider {
59
+ /** Stable key, sent as `dnsCheck.providerId`; the display name may change. */
60
+ id: string;
61
+ /** What `dnsCheck.provider` says. */
62
+ name: string;
63
+ nameservers: RegExp[];
64
+ /** Absent for a host nobody has researched: it is named, and gets the generic instructions. */
65
+ profile?: DnsProviderProfile;
66
+ }
67
+ export declare const DNS_PROVIDERS: readonly DnsProvider[];
68
+ /** The DNS host a zone's nameservers belong to, or null for one not in the table. Entries that are not names are skipped. */
69
+ export declare function dnsProviderForNameservers(nameservers: readonly unknown[]): DnsProvider | null;
70
+ /** The profile for a `dnsCheck.providerId`; undefined for an unknown id or a host with no profile. */
71
+ export declare function dnsProviderProfile(providerId: unknown): DnsProviderProfile | undefined;
72
+ /** How one routing or TXT record is entered at a given host. */
73
+ export interface RecordAtProvider {
74
+ /** The record type the host's editor calls it; null when the host cannot hold it at all (a root record at a no-ALIAS host). */
75
+ type: string | null;
76
+ /** The name to type; "" means leave the Host field empty. */
77
+ name: string;
78
+ /** The value to type, with a trailing dot where the editor needs one. */
79
+ value: string;
80
+ }
81
+ /**
82
+ * The record as the host's editor wants it. `apex` is whether this is the routing record at
83
+ * the zone root; `name` is the relative name (`@` at the root). Without a profile this is the
84
+ * record unchanged, with the root's type named the way every host that can hold it does.
85
+ */
86
+ export declare function recordAtProvider(record: {
87
+ type: string;
88
+ name: string;
89
+ value: string;
90
+ apex: boolean;
91
+ }, profile: DnsProviderProfile | undefined): RecordAtProvider;
@@ -0,0 +1,194 @@
1
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
+ // DNS hosts: how each is recognised from the nameservers a custom domain's zone answers with,
4
+ // and, for the ones researched, how its record editor wants the records we ask for. Detection
5
+ // and profiles live in one table so a host can never be named without the rules that follow
6
+ // from it. Read by the control plane's DNS check, the dashboard and `9pm domains`, and copied
7
+ // into the CLI tarball by scripts/generate-cli-package.mjs, so it must import nothing.
8
+ //
9
+ // Every rule is a property of the DNS HOST (where the nameservers point), never the registrar:
10
+ // records only take effect at the host, whoever sold the domain.
11
+ //
12
+ // Facts come from the BYOD walkthrough's research note (registrars-and-dns-automation.md,
13
+ // researched 2026-10-09 from each provider's help center; the Route 53 row rechecked against
14
+ // AWS's docs on 2026-10-13). That note is not in this repository, so each row cites the
15
+ // provider's own page it relied on, and keeps the note's verified/unverified marks. A fact the note marks unverified or
16
+ // inferred is used only where being wrong is harmless: it never removes information (an
17
+ // "unknown" appendsZone keeps the full-name line) and never becomes a button or a step.
18
+ // Re-check the source before widening any row.
19
+ export const DNS_PROVIDER_SOURCE = "registrars-and-dns-automation.md, 2026-10-09 (Route 53 rechecked 2026-10-13)";
20
+ // Order matters only where two patterns could match one nameserver; none do today.
21
+ export const DNS_PROVIDERS = [
22
+ {
23
+ id: "namecheap",
24
+ name: "Namecheap",
25
+ nameservers: [/(^|\.)registrar-servers\.com$/],
26
+ // Documented: "ALIAS Record", host @; "enter your subdomain just like mysubdomain and not like mysubdomain.domain.tld".
27
+ // https://www.namecheap.com/support/knowledgebase/article.aspx/10128/2237/how-to-create-an-alias-record
28
+ // https://www.namecheap.com/support/knowledgebase/article.aspx/9646/2237/how-to-create-a-cname-record-for-your-domain
29
+ profile: { apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
30
+ },
31
+ {
32
+ id: "godaddy",
33
+ name: "GoDaddy",
34
+ nameservers: [/(^|\.)domaincontrol\.com$/],
35
+ // No ALIAS/ANAME; a CNAME Name "cannot be the @ symbol". Forwarding exists (GoDaddy's own
36
+ // pages); whether it answers over HTTPS is unverified. appendsZone is inferred from the
37
+ // Name rules, so true is used only to hide the full-name line, which GoDaddy would double.
38
+ // https://www.godaddy.com/en/help/add-a-cname-record-19236
39
+ // https://www.godaddy.com/resources/news/beyond-cname-flattening
40
+ profile: {
41
+ apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "provider_forwarding", verified: true, https: "unverified" }] },
42
+ appendsZone: true,
43
+ notes: [],
44
+ source: DNS_PROVIDER_SOURCE,
45
+ },
46
+ },
47
+ {
48
+ id: "cloudflare",
49
+ name: "Cloudflare",
50
+ nameservers: [/(^|\.)ns\.cloudflare\.com$/],
51
+ // A CNAME at the root is flattened. Both name forms are accepted (inferred), so the full
52
+ // name stays visible. No DCV advice until #1990 settles the _acme-challenge question.
53
+ // https://developers.cloudflare.com/dns/cname-flattening/
54
+ // https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-zone-apex/
55
+ profile: { apex: { support: "cname_flattening", typeLabel: "CNAME", hostField: "@" }, appendsZone: "unknown", notes: [], source: DNS_PROVIDER_SOURCE },
56
+ },
57
+ {
58
+ id: "route53",
59
+ name: "Amazon Route 53",
60
+ nameservers: [/awsdns-\d+\./],
61
+ // ALIAS targets only AWS resources or same-zone records, and Route 53 has no forwarding
62
+ // service: so www, a redirect the owner runs inside AWS, or another DNS host. The root's
63
+ // record name is left blank; "@" outside a zone-file import creates a literal label.
64
+ // https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html
65
+ // https://repost.aws/questions/QUt4eylJxXQ5On1SEzH09USQ/route-53-records
66
+ profile: {
67
+ apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "aws_redirect" }, { kind: "move_dns_host" }], hostField: "blank" },
68
+ appendsZone: "unknown",
69
+ notes: [{ id: "route53TxtQuotes", verified: false }],
70
+ source: DNS_PROVIDER_SOURCE,
71
+ },
72
+ },
73
+ // `googledomains.com` may be Squarespace Domains rather than Google Cloud DNS (unverified), so no profile.
74
+ { id: "google-cloud-dns", name: "Google Cloud DNS", nameservers: [/(^|\.)googledomains\.com$/] },
75
+ { id: "squarespace", name: "Squarespace", nameservers: [/(^|\.)squarespacedns\.com$/] },
76
+ {
77
+ id: "porkbun",
78
+ name: "Porkbun",
79
+ nameservers: [/(^|\.)porkbun\.com$/],
80
+ // Documented: host blank for the root, "enter only the subdomain".
81
+ // https://kb.porkbun.com/article/231-how-to-add-dns-records-on-porkbun
82
+ profile: { apex: { support: "alias", typeLabel: "ALIAS", hostField: "blank" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
83
+ },
84
+ {
85
+ id: "namecom",
86
+ name: "Name.com",
87
+ nameservers: [/(^|\.)name\.com$/],
88
+ // https://www.name.com/support/articles/115010493967-adding-an-aname-alias-record
89
+ profile: { apex: { support: "alias", typeLabel: "ANAME", hostField: "blank" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
90
+ },
91
+ {
92
+ id: "gandi",
93
+ name: "Gandi",
94
+ nameservers: [/(^|\.)gandi\.net$/],
95
+ // LiveDNS only; the value needs a trailing dot or the zone is appended to it; no A/AAAA may
96
+ // stay at @ beside the ALIAS.
97
+ // https://docs.gandi.net/en/domain_names/faq/record_types/alias_record.html
98
+ profile: {
99
+ apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" },
100
+ appendsZone: true,
101
+ valueNeedsTrailingDot: true,
102
+ notes: [{ id: "gandiLiveDns", verified: true }],
103
+ source: DNS_PROVIDER_SOURCE,
104
+ },
105
+ },
106
+ { id: "ovhcloud", name: "OVHcloud", nameservers: [/(^|\.)ovh\.net$/] },
107
+ {
108
+ id: "ionos",
109
+ name: "IONOS",
110
+ nameservers: [/(^|\.)ui-dns\.(com|de|org|biz)$/],
111
+ // "CNAME only on subdomains" is IONOS's own; "only HTTP forwarding" for the root is one
112
+ // integrator's report, so forwarding is unverified and rendered as a possibility only.
113
+ // https://www.ionos.com/help/domains/configuring-cname-records-for-subdomains/configuring-a-cname-record-for-a-subdomain/
114
+ profile: {
115
+ apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "provider_forwarding", verified: false, https: "unverified" }] },
116
+ appendsZone: true,
117
+ notes: [],
118
+ source: DNS_PROVIDER_SOURCE,
119
+ },
120
+ },
121
+ {
122
+ id: "hostinger",
123
+ name: "Hostinger",
124
+ nameservers: [/(^|\.)dns-parking\.com$/],
125
+ // A CNAME at @ is shown as ALIAS, one per zone; "@ is appended by default".
126
+ // https://www.hostinger.com/support/10085192-cname-vs-alias-records-at-hostinger/
127
+ profile: {
128
+ apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" },
129
+ appendsZone: true,
130
+ notes: [{ id: "hostingerCnameAtRoot", verified: true }],
131
+ source: DNS_PROVIDER_SOURCE,
132
+ },
133
+ },
134
+ { id: "hover", name: "Hover", nameservers: [/(^|\.)hover\.com$/] },
135
+ {
136
+ id: "wix",
137
+ name: "Wix",
138
+ nameservers: [/(^|\.)wixdns\.net$/],
139
+ // No ALIAS found and no forwarding researched, so no forwarding is offered. Wix's own
140
+ // instruction is to leave the Host blank wherever another one says "@".
141
+ // https://support.wix.com/en/article/adding-or-updating-cname-records-in-your-wix-account
142
+ profile: {
143
+ apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "move_dns_host" }], hostField: "blank" },
144
+ appendsZone: true,
145
+ notes: [],
146
+ source: DNS_PROVIDER_SOURCE,
147
+ },
148
+ },
149
+ { id: "vercel", name: "Vercel", nameservers: [/(^|\.)vercel-dns\.com$/] },
150
+ { id: "ns1", name: "NS1", nameservers: [/(^|\.)nsone\.net$/] },
151
+ { id: "digitalocean", name: "DigitalOcean", nameservers: [/(^|\.)digitalocean\.com$/] },
152
+ { id: "azure", name: "Azure DNS", nameservers: [/azure-dns\./] },
153
+ { id: "hetzner", name: "Hetzner", nameservers: [/(^|\.)hetzner\.(com|de)$/] },
154
+ // ALIAS support is second-hand and the host convention unknown, so no profile yet.
155
+ { id: "dnsimple", name: "DNSimple", nameservers: [/(^|\.)dnsimple\.com$/] },
156
+ { id: "netlify", name: "Netlify", nameservers: [/(^|\.)netlify\.com$/] },
157
+ ];
158
+ /** The DNS host a zone's nameservers belong to, or null for one not in the table. Entries that are not names are skipped. */
159
+ export function dnsProviderForNameservers(nameservers) {
160
+ if (!Array.isArray(nameservers))
161
+ return null;
162
+ for (const ns of nameservers) {
163
+ if (typeof ns !== "string")
164
+ continue;
165
+ const name = ns.toLowerCase().replace(/\.$/, "");
166
+ const hit = DNS_PROVIDERS.find((provider) => provider.nameservers.some((pattern) => pattern.test(name)));
167
+ if (hit)
168
+ return hit;
169
+ }
170
+ return null;
171
+ }
172
+ /** The profile for a `dnsCheck.providerId`; undefined for an unknown id or a host with no profile. */
173
+ export function dnsProviderProfile(providerId) {
174
+ if (typeof providerId !== "string" || providerId === "")
175
+ return undefined;
176
+ return DNS_PROVIDERS.find((provider) => provider.id === providerId)?.profile;
177
+ }
178
+ /**
179
+ * The record as the host's editor wants it. `apex` is whether this is the routing record at
180
+ * the zone root; `name` is the relative name (`@` at the root). Without a profile this is the
181
+ * record unchanged, with the root's type named the way every host that can hold it does.
182
+ */
183
+ export function recordAtProvider(record, profile) {
184
+ const type = String(record.type).toUpperCase();
185
+ const value = profile?.valueNeedsTrailingDot && typeof record.value === "string" && type === "CNAME" && !record.value.endsWith(".") ? `${record.value}.` : record.value;
186
+ if (!record.apex)
187
+ return { type, name: record.name, value };
188
+ if (!profile)
189
+ return { type: "ALIAS", name: record.name, value };
190
+ const apex = profile.apex;
191
+ if (apex.support === "none")
192
+ return { type: null, name: apex.hostField === "blank" ? "" : "@", value };
193
+ return { type: apex.typeLabel, name: apex.hostField === "blank" ? "" : "@", value };
194
+ }
@@ -0,0 +1,176 @@
1
+ export declare const CUSTOM_DOMAIN_EXPIRED_MESSAGE = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
2
+ /** The name the CLI has always exported the expired marker under. */
3
+ export declare const CUSTOM_DOMAIN_EXPIRED_TEXT = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
4
+ export declare const CUSTOM_DOMAINS_SERVICE_MODE_MESSAGE = "Custom domains are not available for service-token apps, so this domain's hostname was removed. Switching the app to another access mode sets it up again, with new DNS verification values to publish.";
5
+ export declare const LEGACY_SERVICE_MODE_MESSAGE = "Custom domains are available only for public apps.";
6
+ /** Whether a stored error is the service-mode teardown marker, in either wording. */
7
+ export declare function isServiceModeProvisioningError(error: string | null | undefined): boolean;
8
+ export declare const CUSTOM_DOMAINS_NOT_CONFIGURED_MESSAGE = "Custom domains are not fully configured on this platform.";
9
+ export declare const CUSTOM_DOMAIN_ROUTE_CONFLICT_MESSAGE = "This domain is already claimed elsewhere on the platform. Contact support to release it.";
10
+ export interface DomainStateDnsInstruction {
11
+ type: string;
12
+ host: string;
13
+ value: string;
14
+ optional?: boolean;
15
+ }
16
+ /** Whether CAA records let one of the platform's certificate authorities issue (#1999). */
17
+ export interface DomainStateCaaCheck {
18
+ status: "none" | "allows" | "blocks";
19
+ recordName: string | null;
20
+ via?: string;
21
+ ownerUnknown?: true;
22
+ allowedIssuers: string[];
23
+ platformIssuers: string[];
24
+ }
25
+ export interface DomainStateDnsCheck {
26
+ provider: string | null;
27
+ /** The DNS host's stable id (src/shared/dns-providers.ts). Absent for an unknown host and from older servers. */
28
+ providerId?: string;
29
+ nameservers: string[];
30
+ /** The zone the records go in, as the check found it. Absent from older servers and when no name answered NS. */
31
+ zone?: string;
32
+ records: DomainStateDnsRecord[];
33
+ caa?: DomainStateCaaCheck;
34
+ }
35
+ /** What one public resolver answered for a record (#1991). */
36
+ export interface DomainStateResolverAnswer {
37
+ resolver: string;
38
+ current: string[];
39
+ matches: boolean;
40
+ /** Seconds it may go on serving this answer from its cache. */
41
+ ttlSeconds?: number;
42
+ }
43
+ export interface DomainStateDnsRecord {
44
+ type: string;
45
+ host: string;
46
+ expected: string;
47
+ current: string[];
48
+ matches: boolean;
49
+ /** The value is published at `<host>.<zone>` instead, the provider having added the zone twice. */
50
+ doubledAt?: string;
51
+ /** The answer is a wildcard's (`*.<parent>`): the name has no record of its own. */
52
+ wildcard?: string;
53
+ /** Seconds `current` may still be served from a resolver's cache; set only when it does not match. */
54
+ ttlSeconds?: number;
55
+ /** Each public resolver's answer, when a second one was asked. `matches` above is the first resolver's, so a disagreement is never a match. */
56
+ resolvers?: DomainStateResolverAnswer[];
57
+ }
58
+ /** What the derivation reads: a domain as any of the domain endpoints returns it. */
59
+ export interface DomainStateInput {
60
+ hostname: string;
61
+ routingStatus: string;
62
+ tlsStatus: string;
63
+ dnsInstructions: DomainStateDnsInstruction[];
64
+ provisioningError: string | null;
65
+ activatedAt?: string | null;
66
+ dnsCheck?: DomainStateDnsCheck;
67
+ providerMessage?: string | null;
68
+ providerMessageAt?: string | null;
69
+ providerMessageSource?: string | null;
70
+ }
71
+ export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
72
+ export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "wildcard" | "resolvers_disagree" | "not_checked" | "apex_addresses";
73
+ export interface DomainRecordStatus {
74
+ type: string;
75
+ name: string;
76
+ fqdn: string;
77
+ expected: string;
78
+ current: string[] | null;
79
+ ok: boolean | null;
80
+ check: DomainRecordCheck;
81
+ doubledAt?: string;
82
+ wildcard?: string;
83
+ ttlSeconds?: number;
84
+ resolvers?: DomainStateResolverAnswer[];
85
+ apex: boolean;
86
+ optional: boolean;
87
+ }
88
+ export declare function domainRecordStatuses(domain: DomainStateInput): DomainRecordStatus[];
89
+ export declare function domainRecordCheck(seen: DomainStateDnsRecord | undefined, atApex: boolean): DomainRecordCheck;
90
+ /**
91
+ * Each resolver's answer as the server reported it, with malformed entries dropped, so a bad entry
92
+ * neither breaks a row nor counts towards a disagreement.
93
+ */
94
+ export declare function resolverAnswers(seen: DomainStateDnsRecord | undefined): DomainStateResolverAnswer[];
95
+ /** Whether one resolver shows the expected value while another shows something else. */
96
+ export declare function resolversDisagree(seen: DomainStateDnsRecord | undefined): boolean;
97
+ /** A cache's time left in whole minutes, at least one: "up to N min". */
98
+ export declare function cacheMinutes(ttlSeconds: number): number;
99
+ /**
100
+ * The `www.<domain>` to connect instead, when this hostname is a bare domain whose DNS host
101
+ * cannot point a root at a hostname at all (#1996): GoDaddy, Route 53, IONOS, Wix. Read only
102
+ * from a host the check both named and identified, so an unknown host, an older server and a
103
+ * domain with no check keep the ordinary records. Routing that is already active is the
104
+ * platform's own evidence that the root record exists, and wins over the profile.
105
+ */
106
+ export declare function rootUnsupportedWwwHostname(domain: DomainStateInput): string | null;
107
+ /**
108
+ * Whether a domain's instructions show "connect www instead" in place of the root record. It
109
+ * reads the status's own next step, so the display and the status cannot disagree: routing
110
+ * that is already active, an unknown host or an older server never show it, and a server's
111
+ * verdict is honoured even for a host this copy's table does not know.
112
+ */
113
+ export declare function showsUseWww(next: {
114
+ action?: string;
115
+ } | null | undefined): boolean;
116
+ export declare function deriveDomainState(domain: DomainStateInput, records?: DomainRecordStatus[]): DomainState;
117
+ export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_resolvers_disagree" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "expired";
118
+ export interface DomainReason {
119
+ code: DomainReasonCode;
120
+ /**
121
+ * The provider's own text, verbatim, when one was reported. `source` says whose it is: only
122
+ * "certificate_authority" may be presented as the certificate authority's; "hostname" is the
123
+ * hostname's own verification note and is worded neutrally.
124
+ */
125
+ providerMessage?: {
126
+ text: string;
127
+ seenAt: string;
128
+ source: "certificate_authority" | "hostname";
129
+ };
130
+ }
131
+ export type DomainNextAction = "none" | "add_records" | "replace_record" | "confirm_records" | "use_www" | "allow_ca_in_your_zone" | "ask_caa_controller" | "check_caa" | "wait" | "start_over" | "change_access_mode" | "add_again" | "recheck" | "contact_support";
132
+ export interface DomainNextRecord {
133
+ type: string;
134
+ host: string;
135
+ value: string;
136
+ /**
137
+ * What to do with this one record. A step can mix them: a routing record pointing elsewhere is
138
+ * "replace" while a missing TXT value beside it is "add", because a name may hold several TXT
139
+ * values and replacing one could remove a value that must stay. TXT is never "replace".
140
+ */
141
+ action: "add" | "replace" | "confirm";
142
+ check: DomainRecordCheck;
143
+ }
144
+ export interface DomainNext {
145
+ /** Who has to act; absent when nobody does (a live domain). */
146
+ who?: "owner" | "platform";
147
+ action: DomainNextAction;
148
+ /** add/replace name only rows checked missing or different; not_checked rows only ever get confirm_records. */
149
+ records?: DomainNextRecord[];
150
+ /** CAA only: the name whose records decide, and the name of the owner's that points at it. */
151
+ caa?: {
152
+ recordName: string;
153
+ via?: string;
154
+ ownerUnknown?: true;
155
+ platformIssuers: string[];
156
+ };
157
+ /** The platform's next background re-read. It reads the status again and restarts nothing. */
158
+ nextCheckAt?: string;
159
+ /** use_www only: the hostname to add to the app instead. */
160
+ hostname?: string;
161
+ }
162
+ export interface DomainStatus {
163
+ state: DomainState;
164
+ /** Null only for a live domain. */
165
+ reason: DomainReason | null;
166
+ next: DomainNext;
167
+ }
168
+ /**
169
+ * The state, why, and the one next step. `state` is exactly deriveDomainState's; `reason` and
170
+ * `next` only refine it. Two rules hold throughout: a record nobody looked at is never called
171
+ * missing (it gets confirm_records, never add), and nothing here restarts validation or
172
+ * suggests that a restart speeds things up - Start over is named only for a failed certificate.
173
+ */
174
+ export declare function deriveDomainStatus(domain: DomainStateInput, options?: {
175
+ nextCheckAt?: string | null;
176
+ }): DomainStatus;