ninepm 0.69.0 → 0.78.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:
@@ -71,6 +73,19 @@ Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9
71
73
  - There is no rename: a different address means a different app.
72
74
  - A custom domain you own, added on the dashboard's Domains page, gives the app an address of your own. Not every plan includes custom domains.
73
75
 
76
+ ### Pages that do not exist
77
+
78
+ A static site or `--entry` worker app answers a path that has no file in one of two ways. Every deploy prints which, as a `Missing pages:` line.
79
+
80
+ - `--no-spa` (the default for new apps): the path returns status 404, with the deploy's own `404.html` when it has one. `/` still serves `index.html`, and extensionless links work: `/menu` serves `menu.html`, or redirects to `/menu/` when only `menu/index.html` exists, and `/menu/` serves `menu/index.html`. Right for a multi-page site, where a removed or mistyped page should say "not found" rather than show the homepage.
81
+ - `--spa`: every path with no file serves `index.html`, so a single-page app that routes in the browser loads on a deep link or a refresh. That includes dotted paths such as `/users/jane.smith` and missing files such as `/missing.json`, which get the app's shell rather than a 404.
82
+
83
+ ```sh
84
+ 9pm deploy ./dist --name my-spa --spa
85
+ ```
86
+
87
+ The app remembers the choice: a deploy without either flag keeps it, and a deploy that fails to publish leaves the previous choice in place. Apps created before this choice existed keep serving `index.html` for every unmatched path until a deploy passes one of the flags. A worker app's own `Response`, a 404 included, is always returned as it is. The flags do not apply to `--bundle` or container apps.
88
+
74
89
  Deploy a built framework output directory:
75
90
 
76
91
  ```sh
@@ -152,12 +167,14 @@ The app's address goes with it: creating an app again under the same slug gives
152
167
 
153
168
  Apps are public by default.
154
169
 
170
+ Changing an app's access mode needs `--switch-mode`. Without it the command changes nothing and prints what the change would do (who loses access, what happens to the app's custom domains) and the command to re-run. Service-token mode locks every person out and removes the app's custom domains; leaving it sets them up again with new DNS records to publish. Creating another service token on an app already in service-token mode needs no flag.
171
+
155
172
  One-time PIN with email/domain allowlist:
156
173
 
157
174
  ```sh
158
175
  9pm access allow email my-app dev@example.com
159
176
  9pm access allow domain my-app example.com
160
- 9pm access otp my-app
177
+ 9pm access otp my-app --switch-mode
161
178
  9pm access revoke email my-app dev@example.com
162
179
  9pm access revoke domain my-app example.com
163
180
  ```
@@ -165,19 +182,19 @@ One-time PIN with email/domain allowlist:
165
182
  Identity provider access:
166
183
 
167
184
  ```sh
168
- 9pm access idp my-app --provider google
185
+ 9pm access idp my-app --provider google --switch-mode
169
186
  ```
170
187
 
171
188
  Machine access:
172
189
 
173
190
  ```sh
174
- 9pm access service create my-app --name backend
191
+ 9pm access service create my-app --name backend --switch-mode
175
192
  ```
176
193
 
177
194
  Return to public:
178
195
 
179
196
  ```sh
180
- 9pm access public my-app
197
+ 9pm access public my-app --switch-mode
181
198
  ```
182
199
 
183
200
  Create an app-scoped runtime token for server-side allowlist management:
@@ -189,11 +206,61 @@ Create an app-scoped runtime token for server-side allowlist management:
189
206
  Use `9pm_app_...` runtime tokens only from server-side code. They can list, add, and remove email/domain grants for one app. They cannot deploy, create databases, create API keys, or manage another app.
190
207
  Service token secrets returned by `access service create` are also server-side secrets.
191
208
 
209
+ ## Custom Domains
210
+
211
+ Connect a domain you own to an app:
212
+
213
+ ```sh
214
+ 9pm domains add my-app www.example.com
215
+ ```
216
+
217
+ This prints the DNS records to publish at your DNS provider, with names relative to your
218
+ domain (`www`, `_acme-challenge.www`) and what DNS shows for each right now. When a TXT value
219
+ is missing, add it as its own TXT record; other TXT values on that name can stay. The root of
220
+ your domain (`@`) cannot hold a CNAME, so a root domain is shown as an `ALIAS` record: add it as
221
+ ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
222
+ cannot be compared from outside, so that row follows the platform's own routing check. Then:
223
+
224
+ ```sh
225
+ 9pm domains wait my-app www.example.com
226
+ ```
227
+
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`,
241
+ `waiting_for_dns`, `dns_unverified` (routing is active but the records could not be checked),
242
+ `issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
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. `advisories` lists hints that
246
+ the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
247
+ Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` means leave the Host
248
+ field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
249
+ `typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
250
+ the text output then suggests connecting `www` instead. `list`, `status` and `wait`
251
+ only read; `add`, `check`, `restart` and `remove` need permission to configure the app.
252
+
253
+ Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
254
+ https://<domain>` for a live one, and for any other its state and the `9pm domains status`
255
+ command to run. If the domains cannot be read within a few seconds, the deploy says so in one
256
+ line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
257
+ it removed.
258
+
192
259
  ## Commands
193
260
 
194
261
  ```sh
195
262
  9pm --version
196
- 9pm deploy [directory] --name <name> [--slug <slug>] [--with-db]
263
+ 9pm deploy [directory] --name <name> [--slug <slug>] [--spa | --no-spa] [--with-db]
197
264
  9pm deploy [directory] --check [--runtime worker --entry <path> (--assets <dir> | --no-assets)]
198
265
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
199
266
  9pm signup
@@ -208,17 +275,24 @@ Service token secrets returned by `access service create` are also server-side s
208
275
  9pm files get <file-id> [--output <path>] [--project <site>]
209
276
  9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
210
277
  9pm files delete <file-id> [--project <site>]
211
- 9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--with-db]
278
+ 9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
212
279
  9pm delete <site> --confirm <slug>
280
+ 9pm domains list [<app>] [--owner <organisation>] [--json]
281
+ 9pm domains add <app> <hostname> [--json]
282
+ 9pm domains status <app> <hostname> [--json]
283
+ 9pm domains check <app> <hostname> [--json]
284
+ 9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
285
+ 9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
286
+ 9pm domains remove <app> <hostname> --confirm <hostname>
213
287
  9pm access show <site>
214
- 9pm access public <site>
215
- 9pm access otp <site>
216
- 9pm access idp <site> [--provider google|github|entra]
288
+ 9pm access public <site> [--switch-mode]
289
+ 9pm access otp <site> [--switch-mode]
290
+ 9pm access idp <site> [--provider google|github|entra] [--switch-mode]
217
291
  9pm access allow email <site> <email>
218
292
  9pm access allow domain <site> <domain>
219
293
  9pm access revoke email <site> <email>
220
294
  9pm access revoke domain <site> <domain>
221
- 9pm access service create <site> [--name <name>] [--duration 8760h]
295
+ 9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
222
296
  9pm access service revoke <site> <service-token-id>
223
297
  9pm access token create <site> [--name <name>]
224
298
  9pm access token revoke <site> <token-id>
@@ -240,6 +314,8 @@ Options:
240
314
  - `--entry <path>` selects the Worker runtime server entry file
241
315
  - `--assets <dir>` makes only that directory of a worker deploy public (remembered for later deploys)
242
316
  - `--no-assets` makes no file of a worker deploy public (remembered for later deploys)
317
+ - `--spa` serves `index.html` for every path that has no file, for single-page apps (remembered for later deploys)
318
+ - `--no-spa` returns 404 for a path that has no file, with the deploy's `404.html` when present; the default for new apps (remembered for later deploys)
243
319
  - `--port <port>` selects the HTTP port exposed by a container app
244
320
  - `--health-path <path>` optionally lets 9pm.ai check container health before marking it healthy
245
321
  - `--region <region>` optionally selects provider-neutral container placement
@@ -8,6 +8,7 @@ export type AppRuntime = "static" | "worker" | "container";
8
8
  export type ContainerRegion = "auto" | "us-east" | "us-west" | "europe" | "asia" | "south-america" | "middle-east" | "oceania" | "africa";
9
9
  export type ManagedFileScope = "user" | "project";
10
10
  export type ManagedFileShareMode = "public" | "login_required";
11
+ export type MissingPagesMode = "legacy" | "spa" | "404";
11
12
  export interface Project {
12
13
  id: string;
13
14
  name: string;
@@ -16,6 +17,7 @@ export interface Project {
16
17
  createdAt: string;
17
18
  updatedAt: string;
18
19
  workerPublicAssets?: string | null;
20
+ missingPages?: MissingPagesMode;
19
21
  }
20
22
  export interface AppUserFilesUsage {
21
23
  usedBytes: number;
@@ -0,0 +1,15 @@
1
+ /** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
2
+ export declare function guessZoneApex(hostname: string): string;
3
+ /**
4
+ * The record name the way most registrars' Host field wants it, relative to the zone:
5
+ * `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
6
+ */
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;
@@ -0,0 +1,43 @@
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
+ // Record names for a custom domain's DNS instructions, written the way a registrar's Host
4
+ // field wants them. Read by the dashboard's DNS table and by `9pm domains`, and copied into
5
+ // the CLI tarball by scripts/generate-cli-package.mjs, so it must import nothing.
6
+ // Second-level labels that sit under a two-letter country code as part of the public suffix
7
+ // (example.co.uk, example.com.au). Not a public suffix list: it covers the common shapes, and
8
+ // the full name is always shown beside the short one for the cases it misses.
9
+ const SECOND_LEVEL = new Set(["co", "com", "net", "org", "gov", "ac", "edu", "ne", "or", "go"]);
10
+ /** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
11
+ export function guessZoneApex(hostname) {
12
+ const labels = hostname.toLowerCase().replace(/\.$/, "").split(".");
13
+ if (labels.length >= 3 && SECOND_LEVEL.has(labels[labels.length - 2]) && labels[labels.length - 1].length === 2) {
14
+ return labels.slice(-3).join(".");
15
+ }
16
+ return labels.slice(-2).join(".");
17
+ }
18
+ /**
19
+ * The record name the way most registrars' Host field wants it, relative to the zone:
20
+ * `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
21
+ */
22
+ export function relativeRecordName(recordHost, apex) {
23
+ const host = recordHost.toLowerCase().replace(/\.$/, "");
24
+ if (host === apex)
25
+ return "@";
26
+ return host.endsWith(`.${apex}`) ? host.slice(0, -(apex.length + 1)) : host;
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
+ }