ninepm 0.78.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
@@ -242,7 +242,17 @@ saying whether they are the certificate authority's),
242
242
  `issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
243
243
  say, and its `check` says why (`not_checked`, or `apex_addresses`). A record whose value was found
244
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
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
246
256
  the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
247
257
  Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` means leave the Host
248
258
  field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
@@ -29,16 +29,32 @@ export interface DomainStateDnsCheck {
29
29
  nameservers: string[];
30
30
  /** The zone the records go in, as the check found it. Absent from older servers and when no name answered NS. */
31
31
  zone?: string;
32
- records: Array<{
33
- type: string;
34
- host: string;
35
- expected: string;
36
- current: string[];
37
- matches: boolean;
38
- doubledAt?: string;
39
- }>;
32
+ records: DomainStateDnsRecord[];
40
33
  caa?: DomainStateCaaCheck;
41
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
+ }
42
58
  /** What the derivation reads: a domain as any of the domain endpoints returns it. */
43
59
  export interface DomainStateInput {
44
60
  hostname: string;
@@ -53,7 +69,7 @@ export interface DomainStateInput {
53
69
  providerMessageSource?: string | null;
54
70
  }
55
71
  export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
56
- export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "not_checked" | "apex_addresses";
72
+ export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "wildcard" | "resolvers_disagree" | "not_checked" | "apex_addresses";
57
73
  export interface DomainRecordStatus {
58
74
  type: string;
59
75
  name: string;
@@ -63,10 +79,23 @@ export interface DomainRecordStatus {
63
79
  ok: boolean | null;
64
80
  check: DomainRecordCheck;
65
81
  doubledAt?: string;
82
+ wildcard?: string;
83
+ ttlSeconds?: number;
84
+ resolvers?: DomainStateResolverAnswer[];
66
85
  apex: boolean;
67
86
  optional: boolean;
68
87
  }
69
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;
70
99
  /**
71
100
  * The `www.<domain>` to connect instead, when this hostname is a bare domain whose DNS host
72
101
  * cannot point a root at a hostname at all (#1996): GoDaddy, Route 53, IONOS, Wix. Read only
@@ -85,7 +114,7 @@ export declare function showsUseWww(next: {
85
114
  action?: string;
86
115
  } | null | undefined): boolean;
87
116
  export declare function deriveDomainState(domain: DomainStateInput, records?: DomainRecordStatus[]): DomainState;
88
- export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "expired";
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";
89
118
  export interface DomainReason {
90
119
  code: DomainReasonCode;
91
120
  /**
@@ -49,15 +49,20 @@ export function domainRecordStatuses(domain) {
49
49
  const seen = domain.dnsCheck?.records.find((entry) => entry.type.toUpperCase() === type && normalizeDnsName(entry.host) === fqdn && entry.expected === expected);
50
50
  const atApex = type === "CNAME" && fqdn === apex;
51
51
  const check = domainRecordCheck(seen, atApex);
52
+ const resolvers = resolverAnswers(seen);
53
+ const ttl = seen?.ttlSeconds;
52
54
  return {
53
55
  type,
54
56
  name: relativeRecordName(fqdn, apex),
55
57
  fqdn,
56
58
  expected: instruction.value,
57
59
  current: seen ? seen.current : null,
58
- ok: check === "found" ? true : check === "missing" || check === "different" || check === "doubled" ? false : null,
60
+ ok: check === "found" ? true : check === "missing" || check === "different" || check === "doubled" || check === "wildcard" ? false : null,
59
61
  check,
60
62
  ...(check === "doubled" && seen?.doubledAt ? { doubledAt: seen.doubledAt } : {}),
63
+ ...(check === "wildcard" && seen?.wildcard ? { wildcard: seen.wildcard } : {}),
64
+ ...(check !== "found" && typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}),
65
+ ...(resolvers.length > 0 ? { resolvers } : {}),
61
66
  apex: atApex,
62
67
  optional: instruction.optional === true,
63
68
  };
@@ -67,10 +72,14 @@ const IP_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i;
67
72
  // The server looks a CNAME up and, finding none, falls back to the name's A records. At the
68
73
  // zone root that is exactly what a correct ALIAS/ANAME or flattened CNAME answers: addresses,
69
74
  // never the target name. So addresses at the apex are not evidence of a wrong record; they
70
- // cannot be compared from outside at all.
71
- function domainRecordCheck(seen, atApex) {
75
+ // cannot be compared from outside at all. Exported so a client that renders records without a
76
+ // full status (the dashboard's table) reads the same verdict per row.
77
+ export function domainRecordCheck(seen, atApex) {
72
78
  if (!seen)
73
79
  return "not_checked";
80
+ // Ahead of `matches`, so no shape of answer turns a disagreement between caches into "found".
81
+ if (resolversDisagree(seen))
82
+ return "resolvers_disagree";
74
83
  if (seen.matches)
75
84
  return "found";
76
85
  if (seen.doubledAt)
@@ -79,8 +88,37 @@ function domainRecordCheck(seen, atApex) {
79
88
  return "missing";
80
89
  if (atApex && seen.current.every((value) => IP_ADDRESS.test(value.trim())))
81
90
  return "apex_addresses";
91
+ if (typeof seen.wildcard === "string" && seen.wildcard !== "")
92
+ return "wildcard";
82
93
  return "different";
83
94
  }
95
+ /**
96
+ * Each resolver's answer as the server reported it, with malformed entries dropped, so a bad entry
97
+ * neither breaks a row nor counts towards a disagreement.
98
+ */
99
+ export function resolverAnswers(seen) {
100
+ if (!seen || !Array.isArray(seen.resolvers))
101
+ return [];
102
+ return seen.resolvers.flatMap((answer) => {
103
+ if (answer === null || typeof answer !== "object")
104
+ return [];
105
+ const entry = answer;
106
+ if (typeof entry.resolver !== "string" || typeof entry.matches !== "boolean" || !Array.isArray(entry.current))
107
+ return [];
108
+ const current = entry.current.filter((value) => typeof value === "string");
109
+ const ttl = entry.ttlSeconds;
110
+ return [{ resolver: entry.resolver, current, matches: entry.matches, ...(typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}) }];
111
+ });
112
+ }
113
+ /** Whether one resolver shows the expected value while another shows something else. */
114
+ export function resolversDisagree(seen) {
115
+ const answers = resolverAnswers(seen);
116
+ return answers.some((answer) => answer.matches) && answers.some((answer) => !answer.matches);
117
+ }
118
+ /** A cache's time left in whole minutes, at least one: "up to N min". */
119
+ export function cacheMinutes(ttlSeconds) {
120
+ return Math.max(1, Math.ceil(ttlSeconds / 60));
121
+ }
84
122
  // Unproven is decided by `ok`, not by listing check values: a check this code does not know yet
85
123
  // carries ok null and so reads as unknown, never as found. The one exception is an apex
86
124
  // answering with addresses, which the platform's own active routing vouches for.
@@ -232,7 +270,14 @@ export function deriveDomainStatus(domain, options = {}) {
232
270
  }
233
271
  const routingActive = domain.routingStatus === "active";
234
272
  const unproven = required.filter((record) => isUnproven(record, routingActive));
235
- const code = unproven.length > 0 && unproven.every((record) => record.check === "apex_addresses") ? "apex_unverifiable" : "dns_not_checked";
273
+ // A disagreement is named only when every unproven record is one, or an apex that cannot be
274
+ // compared from outside: a record nobody could read still makes it "not checked".
275
+ const explained = unproven.length > 0 && unproven.every((record) => record.check === "apex_addresses" || record.check === "resolvers_disagree");
276
+ const code = !explained
277
+ ? "dns_not_checked"
278
+ : unproven.some((record) => record.check === "resolvers_disagree")
279
+ ? "dns_resolvers_disagree"
280
+ : "apex_unverifiable";
236
281
  return {
237
282
  state,
238
283
  reason: withMessage(code),
package/dist/index.d.ts CHANGED
@@ -127,7 +127,7 @@ export interface CreatedServiceToken {
127
127
  clientSecret: string;
128
128
  }
129
129
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
130
- export declare const CLI_VERSION = "0.78.0";
130
+ export declare const CLI_VERSION = "0.79.0";
131
131
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
132
132
  export interface StoredCredentialEntry {
133
133
  token: string;
@@ -789,6 +789,7 @@ export declare const DOMAIN_WAIT_EXIT: {
789
789
  export declare function domainStatusJson(domain: CliDomain, app: string, checkedAt: string): DomainStatusJson;
790
790
  export declare function domainStateLabel(state: string): string;
791
791
  export declare function domainWaitExitCode(status: Pick<DomainStatusJson, "state" | "next">): number;
792
+ export declare function nextCheckText(nextCheckAt: unknown): string | null;
792
793
  export interface DomainFormatOptions {
793
794
  owner?: string;
794
795
  target?: AccessApiTarget;
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-ho
15
15
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
16
16
  import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
17
17
  import { dnsProviderProfile, recordAtProvider } from "./dns-providers.js";
18
- import { deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
18
+ import { cacheMinutes, deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
19
19
  import { buildFileManifest, readManifestFile } from "./manifest.js";
20
20
  import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
21
21
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
@@ -52,7 +52,7 @@ export function deployDatabaseClause(databases) {
52
52
  const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
53
53
  return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
54
54
  }
55
- export const CLI_VERSION = "0.78.0";
55
+ export const CLI_VERSION = "0.79.0";
56
56
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
57
57
  const TOKEN_SERVICE = "9pm";
58
58
  const TOKEN_ACCOUNT = "default";
@@ -3328,9 +3328,33 @@ function providerMessageLine(reason) {
3328
3328
  return `${who} reported (${message.seenAt}): ${message.text}`;
3329
3329
  }
3330
3330
  const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
3331
+ // The server's next background check as "HH:MM UTC" (UTC so the line reads the same on any
3332
+ // machine); null when there is none or it is not a date, so no time is promised from it.
3333
+ export function nextCheckText(nextCheckAt) {
3334
+ if (typeof nextCheckAt !== "string" || nextCheckAt === "")
3335
+ return null;
3336
+ const at = new Date(nextCheckAt);
3337
+ return Number.isNaN(at.getTime()) ? null : `${at.toISOString().slice(11, 16)} UTC`;
3338
+ }
3339
+ // "up to N min" for a cache's time left, or nothing when the resolver did not say.
3340
+ function forUpTo(ttlSeconds) {
3341
+ return typeof ttlSeconds === "number" ? ` for up to ${cacheMinutes(ttlSeconds)} min` : "";
3342
+ }
3343
+ function answerText(current) {
3344
+ return current.length > 0 ? current.join(", ") : "nothing";
3345
+ }
3331
3346
  function recordStateText(record, routing) {
3332
3347
  if (record.ok === true)
3333
3348
  return "found";
3349
+ if (record.check === "resolvers_disagree") {
3350
+ // Two caches that disagree prove neither answer current: no "found", no "replace", and neither answer called old.
3351
+ const answers = record.resolvers ?? [];
3352
+ const seeing = answers.filter((answer) => answer.matches).map((answer) => answer.resolver);
3353
+ const others = answers
3354
+ .filter((answer) => !answer.matches)
3355
+ .map((answer) => `${answer.resolver} sees ${answerText(answer.current)}${typeof answer.ttlSeconds === "number" ? ` (and may keep that answer${forUpTo(answer.ttlSeconds)})` : ""}`);
3356
+ return `public resolvers disagree: ${seeing.join(" and ")} ${seeing.length > 1 ? "see" : "sees"} the value shown, ${others.join("; ")}. That usually means a recent change is still spreading; make sure your DNS host shows the value shown, then check again`;
3357
+ }
3334
3358
  if (record.check === "doubled") {
3335
3359
  // The same name the NAME column shows, so the hint never contradicts the host's convention.
3336
3360
  const name = record.nameAtProvider ?? record.name;
@@ -3342,17 +3366,32 @@ function recordStateText(record, routing) {
3342
3366
  if (record.check === "apex_addresses") {
3343
3367
  return routing === "active"
3344
3368
  ? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
3345
- : `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, so this waits on the platform's routing check`;
3369
+ : // A parking page answers with addresses too, so nothing here says the record is right: the owner confirms it.
3370
+ `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, and a parking page answers with addresses too: make sure the ALIAS or ANAME record at the root points at the value shown. The platform's routing check confirms it`;
3346
3371
  }
3347
3372
  if (record.ok === null || record.current === null)
3348
3373
  return "not checked: couldn't read your DNS just now";
3374
+ // TXT before wildcard: a name may hold several TXT values another certificate still needs, so a
3375
+ // TXT value is always added beside them, never "changed", whatever answers at the name.
3349
3376
  if (record.type === "TXT") {
3350
- const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}`;
3377
+ const from = record.check === "wildcard" && record.wildcard ? ` (from the wildcard ${record.wildcard})` : "";
3378
+ const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}${from}`;
3351
3379
  return `${now}: ${TXT_KEEP_OTHERS}`;
3352
3380
  }
3381
+ if (record.check === "wildcard") {
3382
+ const name = record.nameAtProvider ?? record.name;
3383
+ // A name holding a CNAME can hold nothing else, so an explicit record equal to the wildcard's answer is changed, not joined.
3384
+ return `no ${record.type} record of its own yet: your DNS host's wildcard ${record.wildcard ?? ""} answers for it (${record.current.join(", ")}). Add one at ${name === "" ? "the root" : name} with the value shown; the wildcard can stay. If your DNS host already lists a record at this name, change that one instead`;
3385
+ }
3353
3386
  if (record.current.length === 0)
3354
3387
  return "not published yet";
3355
- return `currently ${record.current.join(", ")}: point it at the value shown`;
3388
+ // A changed record can keep its old answer in public caches for its TTL, which is said so the owner does not change it twice.
3389
+ const recent = typeof record.ttlSeconds === "number" ? ` (if you changed it in the last ${cacheMinutes(record.ttlSeconds)} min, this may be the old answer still cached)` : "";
3390
+ // Two resolvers can each hold a different old answer; both are shown, as neither is the expected value.
3391
+ const shown = record.current.join(", ");
3392
+ const others = (record.resolvers ?? []).filter((answer) => answer.current.join(", ") !== shown);
3393
+ const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
3394
+ return `currently ${shown}${also}${recent}: point it at the value shown`;
3356
3395
  }
3357
3396
  // A printed command carries --owner and, when the destination is not the built-in default,
3358
3397
  // --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
@@ -3510,6 +3549,10 @@ export function formatDomainStatus(status, options = {}) {
3510
3549
  }
3511
3550
  else if (status.state === "issuing_certificate") {
3512
3551
  lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
3552
+ const nextCheck = nextCheckText(status.next?.nextCheckAt);
3553
+ // A restart is not offered here: nothing measured says it speeds issuance up.
3554
+ if (nextCheck)
3555
+ lines.push(`The platform checks it again around ${nextCheck} on its own; nothing needs restarting.`);
3513
3556
  }
3514
3557
  lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3515
3558
  return lines;
@@ -3525,12 +3568,16 @@ export function domainWaitTimeoutMessage(status) {
3525
3568
  return "Timed out: a CAA record stops the certificate from being issued. Change it as described above, then wait again.";
3526
3569
  }
3527
3570
  if (status.state === "issuing_certificate") {
3528
- return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
3571
+ const nextCheck = nextCheckText(status.next?.nextCheckAt);
3572
+ return `Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.${nextCheck ? ` Its next automatic check is around ${nextCheck}.` : ""}`;
3529
3573
  }
3530
3574
  const required = status.records.filter((record) => !record.optional);
3531
3575
  if (required.some((record) => record.ok === false)) {
3532
3576
  return "Timed out: records above are still missing. Publish them, then wait again.";
3533
3577
  }
3578
+ if (required.some((record) => record.check === "resolvers_disagree")) {
3579
+ return "Timed out: public resolvers still disagree about a record above. Make sure your DNS host shows the value shown, then wait again.";
3580
+ }
3534
3581
  return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
3535
3582
  }
3536
3583
  export function formatDomainsList(statuses) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [