authflow-cli 0.4.1 → 0.5.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.
Files changed (49) hide show
  1. package/README.md +157 -5
  2. package/dist/checks.d.ts +30 -0
  3. package/dist/checks.js +36 -0
  4. package/dist/checks.js.map +1 -0
  5. package/dist/client-conformance.d.ts +5 -1
  6. package/dist/client-conformance.js +9 -1
  7. package/dist/client-conformance.js.map +1 -1
  8. package/dist/commands/doctor.js +5 -1
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/register.js +30 -2
  11. package/dist/commands/register.js.map +1 -1
  12. package/dist/commands/set-domain.d.ts +34 -0
  13. package/dist/commands/set-domain.js +162 -0
  14. package/dist/commands/set-domain.js.map +1 -0
  15. package/dist/config.d.ts +32 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/custom-domain.d.ts +96 -0
  18. package/dist/custom-domain.js +289 -0
  19. package/dist/custom-domain.js.map +1 -0
  20. package/dist/doctor.d.ts +31 -23
  21. package/dist/doctor.js +271 -57
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/google-redirect.d.ts +5 -1
  24. package/dist/google-redirect.js +11 -5
  25. package/dist/google-redirect.js.map +1 -1
  26. package/dist/mcp/server.d.ts +8 -1
  27. package/dist/mcp/server.js +137 -7
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/program.js +4 -1
  30. package/dist/program.js.map +1 -1
  31. package/dist/rail.d.ts +16 -0
  32. package/dist/rail.js +39 -0
  33. package/dist/rail.js.map +1 -1
  34. package/dist/resource-urls.d.ts +27 -0
  35. package/dist/resource-urls.js +56 -0
  36. package/dist/resource-urls.js.map +1 -0
  37. package/dist/status.d.ts +15 -1
  38. package/dist/status.js +59 -5
  39. package/dist/status.js.map +1 -1
  40. package/dist/surface.d.ts +1 -0
  41. package/dist/surface.js +1 -0
  42. package/dist/surface.js.map +1 -1
  43. package/dist/tenant-host.d.ts +49 -0
  44. package/dist/tenant-host.js +367 -0
  45. package/dist/tenant-host.js.map +1 -0
  46. package/dist/version.d.ts +10 -0
  47. package/dist/version.js +27 -0
  48. package/dist/version.js.map +1 -0
  49. package/package.json +4 -4
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Where a resource actually answers, for the checks that probe it.
3
+ *
4
+ * doctor and the Google check used to build `{issuer}/{slug}/mcp` and the matching PRM URL
5
+ * themselves. That is only true for a resource on the gateway host. A resource on its own hostname
6
+ * answers only there, and its canonical resource URI is the tenant's URL, so the receipt is the
7
+ * source of truth and the issuer-plus-slug form is the fallback for when there is no receipt.
8
+ */
9
+ const wellKnownPrefix = "/.well-known/oauth-protected-resource";
10
+ /**
11
+ * RFC 9728 §3.1: the well-known suffix is inserted between the host and the path of the resource
12
+ * identifier, and a terminating slash after a bare host is dropped. `https://h/mcp` therefore has
13
+ * its metadata at `https://h/.well-known/oauth-protected-resource/mcp`, and `https://h` at
14
+ * `https://h/.well-known/oauth-protected-resource`.
15
+ */
16
+ export function protectedResourceMetadataUrl(resource) {
17
+ let url;
18
+ try {
19
+ url = new URL(resource);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ const path = url.pathname === "/" ? "" : url.pathname;
25
+ return `${url.origin}${wellKnownPrefix}${path}${url.search}`;
26
+ }
27
+ /** The canonical URI of a resource served on a tenant host. */
28
+ export function publicResourceUrl(host, path) {
29
+ return `https://${host}${path}`;
30
+ }
31
+ /** What a resource on the gateway host answers at: `{issuer}/{slug}/mcp` and its PRM. */
32
+ export function slugEndpoints(base, slug) {
33
+ const encoded = encodeURIComponent(slug);
34
+ return {
35
+ gatewayUrl: `${base}/${encoded}/mcp`,
36
+ prmUrl: `${base}${wellKnownPrefix}/${encoded}/mcp`,
37
+ };
38
+ }
39
+ /**
40
+ * Endpoints from the receipt when it supplies them, per field, falling back to the slug form.
41
+ * `base` is the issuer with no trailing slash.
42
+ */
43
+ export function resolveResourceEndpoints(base, slug, receipt) {
44
+ const fallback = slugEndpoints(base, slug);
45
+ const resource = receipt?.resource?.trim() || undefined;
46
+ const gateway = receipt?.gateway_url?.trim() || undefined;
47
+ const prm = receipt?.prm_url?.trim() || undefined;
48
+ const source = resource ?? gateway;
49
+ return {
50
+ resource,
51
+ gatewayUrl: gateway ?? resource ?? fallback.gatewayUrl,
52
+ prmUrl: prm ?? (source ? protectedResourceMetadataUrl(source) : undefined) ?? fallback.prmUrl,
53
+ publicHost: receipt?.public_host?.trim().toLowerCase() || undefined,
54
+ };
55
+ }
56
+ //# sourceMappingURL=resource-urls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource-urls.js","sourceRoot":"","sources":["../src/resource-urls.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,MAAM,eAAe,GAAG,uCAAuC,CAAC;AAahE;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAAC,QAAgB;IAC3D,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;IACtD,OAAO,GAAG,GAAG,CAAC,MAAM,GAAG,eAAe,GAAG,IAAI,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;AAC/D,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,iBAAiB,CAAC,IAAY,EAAE,IAAY;IAC1D,OAAO,WAAW,IAAI,GAAG,IAAI,EAAE,CAAC;AAClC,CAAC;AAED,yFAAyF;AACzF,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,IAAY;IACtD,MAAM,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACzC,OAAO;QACL,UAAU,EAAE,GAAG,IAAI,IAAI,OAAO,MAAM;QACpC,MAAM,EAAE,GAAG,IAAI,GAAG,eAAe,IAAI,OAAO,MAAM;KACnD,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CACtC,IAAY,EACZ,IAAY,EACZ,OAA6B;IAE7B,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;IACxD,MAAM,OAAO,GAAG,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;IAC1D,MAAM,GAAG,GAAG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;IAClD,MAAM,MAAM,GAAG,QAAQ,IAAI,OAAO,CAAC;IAEnC,OAAO;QACL,QAAQ;QACR,UAAU,EAAE,OAAO,IAAI,QAAQ,IAAI,QAAQ,CAAC,UAAU;QACtD,MAAM,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,4BAA4B,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,QAAQ,CAAC,MAAM;QAC7F,UAAU,EAAE,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,SAAS;KACpE,CAAC;AACJ,CAAC"}
package/dist/status.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RegistrationReceipt } from "./config.js";
1
+ import type { CustomDomainRecord, RegistrationReceipt } from "./config.js";
2
2
  /**
3
3
  * Turning a receipt into "what is done, what is left, and what do I type next".
4
4
  *
@@ -20,14 +20,28 @@ export type ReadinessStep = {
20
20
  name: string;
21
21
  done: boolean;
22
22
  detail: string;
23
+ /** Extra lines printed under the detail, such as the DNS records still to add. */
24
+ lines?: string[];
23
25
  /** What to run next. Absent once the step is done. */
24
26
  command?: string;
25
27
  };
28
+ /** The custom-domain claim, structured for `status --json` and for agents. */
29
+ export type ReadinessCustomDomain = {
30
+ host?: string;
31
+ path: string;
32
+ state?: string;
33
+ cnameTarget?: string;
34
+ records: CustomDomainRecord[];
35
+ lastError?: string;
36
+ checkedAt?: string;
37
+ };
26
38
  export type Readiness = {
27
39
  slug: string;
28
40
  status: string;
29
41
  ready: boolean;
30
42
  gatewayUrl?: string;
43
+ /** Present when the resource is, or is becoming, served on the tenant's own hostname. */
44
+ customDomain?: ReadinessCustomDomain;
31
45
  steps: ReadinessStep[];
32
46
  };
33
47
  export declare function resourceReadiness(receipt: RegistrationReceipt): Readiness;
package/dist/status.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { customDomainState, defaultPublicPath, formatDnsRecords, registrarNameHint, setDomainCommandLine, summarizeCustomDomain, } from "./custom-domain.js";
1
2
  /**
2
3
  * Turning a receipt into "what is done, what is left, and what do I type next".
3
4
  *
@@ -77,14 +78,41 @@ export function resourceReadiness(receipt) {
77
78
  : undefined,
78
79
  },
79
80
  ];
80
- // Conformance is not a receipt field: the rail records registration state, not whether the origin
81
- // has been exercised. Prompt for it whenever the origin exists so it is not silently skipped.
81
+ // A custom host is a requirement of its own: until the tenant's DNS is in place and the edge has
82
+ // a certificate, the resource's URL does not answer, and from the outside that looks exactly like
83
+ // an origin that was never wired up.
84
+ const customDomain = customDomainReadiness(receipt);
85
+ if (customDomain || blocked("custom_domain_dns")) {
86
+ const done = customDomain?.state === "ready" && !blocked("custom_domain_dns");
87
+ const records = customDomain?.records ?? [];
88
+ steps.push({
89
+ name: "custom domain DNS",
90
+ done,
91
+ detail: `${customDomain?.host ?? "(host not reported)"}: ${summarizeCustomDomain(receipt)}`,
92
+ lines: records.length > 0
93
+ ? [
94
+ "",
95
+ ...formatDnsRecords(records),
96
+ ...(!done && customDomain?.host ? ["", ...registrarNameHint(customDomain.host)] : []),
97
+ ]
98
+ : undefined,
99
+ command: done ? undefined : setDomainCommandLine(slug),
100
+ });
101
+ }
102
+ // Conformance is recorded on the receipt after POST /origin/v1/conformance. Prompt until
103
+ // a run has passed so it is not silently skipped, and stop prompting after a pass.
82
104
  if (receipt.origin_base_uri) {
105
+ const probed = receipt.last_probe_passed === true;
106
+ const at = receipt.last_probe_at_utc?.trim();
83
107
  steps.push({
84
108
  name: "conformance probed",
85
- done: false,
86
- detail: "run the probe to confirm the origin fails closed on a bad identity header",
87
- command: "authflow probe",
109
+ done: probed,
110
+ detail: probed
111
+ ? `passed${at ? ` at ${at}` : ""}`
112
+ : at
113
+ ? `last run failed at ${at}`
114
+ : "run the probe to confirm the origin fails closed on a bad identity header",
115
+ command: probed ? undefined : "authflow probe",
88
116
  });
89
117
  }
90
118
  return {
@@ -92,9 +120,27 @@ export function resourceReadiness(receipt) {
92
120
  status: receipt.status ?? (pending.length === 0 ? "live" : "draft"),
93
121
  ready: pending.length === 0 && receipt.status === "live",
94
122
  gatewayUrl: receipt.gateway_url ?? receipt.resource,
123
+ customDomain,
95
124
  steps,
96
125
  };
97
126
  }
127
+ /** Undefined for a resource on the gateway host, so its status output does not change at all. */
128
+ function customDomainReadiness(receipt) {
129
+ const domain = receipt.custom_domain;
130
+ const host = receipt.public_host?.trim();
131
+ if (!domain && !host) {
132
+ return undefined;
133
+ }
134
+ return {
135
+ host,
136
+ path: receipt.public_path?.trim() || defaultPublicPath,
137
+ state: customDomainState(receipt),
138
+ cnameTarget: domain?.cname_target ?? undefined,
139
+ records: domain?.records ?? [],
140
+ lastError: domain?.last_error ?? undefined,
141
+ checkedAt: domain?.checked_at ?? undefined,
142
+ };
143
+ }
98
144
  function attachStripeCommand(slug) {
99
145
  return (`authflow connect --slug ${slug}` +
100
146
  ` (or authflow set-connected-account --slug ${slug} --account acct_…)`);
@@ -111,7 +157,15 @@ export function formatReadiness(readiness) {
111
157
  for (const step of readiness.steps) {
112
158
  lines.push(` ${step.done ? "[x]" : "[ ]"} ${step.name}`);
113
159
  lines.push(` ${step.detail}`);
160
+ const extras = step.lines ?? [];
161
+ for (const extra of extras) {
162
+ lines.push(extra ? ` ${extra}` : "");
163
+ }
114
164
  if (step.command) {
165
+ // Keep the command visually apart from a block of records it refers to.
166
+ if (extras.length > 0) {
167
+ lines.push("");
168
+ }
115
169
  lines.push(` next: ${step.command}`);
116
170
  }
117
171
  }
@@ -1 +1 @@
1
- {"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"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"}
package/dist/surface.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare const mcpToolToCliCommand: {
15
15
  readonly authflow_start_stripe_connect: "connect";
16
16
  readonly authflow_set_connected_account: "set-connected-account";
17
17
  readonly authflow_set_origin: "set-origin";
18
+ readonly authflow_set_custom_domain: "set-domain";
18
19
  readonly authflow_probe: "probe";
19
20
  readonly authflow_mint_test_header: "mint-header";
20
21
  readonly authflow_rotate_origin_key: "rotate-origin-key";
package/dist/surface.js CHANGED
@@ -15,6 +15,7 @@ export const mcpToolToCliCommand = {
15
15
  authflow_start_stripe_connect: "connect",
16
16
  authflow_set_connected_account: "set-connected-account",
17
17
  authflow_set_origin: "set-origin",
18
+ authflow_set_custom_domain: "set-domain",
18
19
  authflow_probe: "probe",
19
20
  authflow_mint_test_header: "mint-header",
20
21
  authflow_rotate_origin_key: "rotate-origin-key",
@@ -1 +1 @@
1
- {"version":3,"file":"surface.js","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,eAAe,EAAE,QAAQ;IACzB,eAAe,EAAE,QAAQ;IACzB,0BAA0B,EAAE,UAAU;IACtC,qBAAqB,EAAE,KAAK;IAC5B,uBAAuB,EAAE,MAAM;IAC/B,eAAe,EAAE,QAAQ;IACzB,yBAAyB,EAAE,MAAM;IACjC,6BAA6B,EAAE,SAAS;IACxC,8BAA8B,EAAE,uBAAuB;IACvD,mBAAmB,EAAE,YAAY;IACjC,cAAc,EAAE,OAAO;IACvB,yBAAyB,EAAE,aAAa;IACxC,0BAA0B,EAAE,mBAAmB;CACvC,CAAC"}
1
+ {"version":3,"file":"surface.js","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,eAAe,EAAE,QAAQ;IACzB,eAAe,EAAE,QAAQ;IACzB,0BAA0B,EAAE,UAAU;IACtC,qBAAqB,EAAE,KAAK;IAC5B,uBAAuB,EAAE,MAAM;IAC/B,eAAe,EAAE,QAAQ;IACzB,yBAAyB,EAAE,MAAM;IACjC,6BAA6B,EAAE,SAAS;IACxC,8BAA8B,EAAE,uBAAuB;IACvD,mBAAmB,EAAE,YAAY;IACjC,0BAA0B,EAAE,YAAY;IACxC,cAAc,EAAE,OAAO;IACvB,yBAAyB,EAAE,aAAa;IACxC,0BAA0B,EAAE,mBAAmB;CACvC,CAAC"}
@@ -0,0 +1,49 @@
1
+ import { type PeerCertificate } from "node:tls";
2
+ import { type DoctorCheck } from "./checks.js";
3
+ import type { RegistrationReceipt } from "./config.js";
4
+ /**
5
+ * `authflow doctor --host`: does a tenant's own hostname work, end to end, from outside?
6
+ *
7
+ * The rail can report a custom domain as ready while a tenant's registrar still serves a stale
8
+ * record, and a rail-side check cannot tell, because the rail is not the one resolving the name.
9
+ * These checks look the way a client does: resolve the three records, handshake TLS, follow a plain
10
+ * http request, then fetch the discovery documents and the 401 challenge on the tenant host.
11
+ *
12
+ * Every lookup goes through an injectable seam (DNS, TLS) or the shared fetch, so none of this
13
+ * needs a network to test.
14
+ */
15
+ export type DnsResolver = {
16
+ resolveCname(hostname: string): Promise<string[]>;
17
+ resolveTxt(hostname: string): Promise<string[][]>;
18
+ };
19
+ export declare const systemDnsResolver: DnsResolver;
20
+ export type TlsPeer = {
21
+ authorized: boolean;
22
+ /** Why the certificate is not trusted, as Node reports it (a code such as CERT_HAS_EXPIRED). */
23
+ authorizationError?: string;
24
+ issuer: string;
25
+ subject: string;
26
+ validTo: Date;
27
+ };
28
+ export type TlsProbe = (host: string, port: number) => Promise<TlsPeer>;
29
+ /** The network seams, injectable so tests never touch DNS or open a socket. */
30
+ export type HostProbes = {
31
+ dns?: DnsResolver;
32
+ tls?: TlsProbe;
33
+ now?: () => Date;
34
+ };
35
+ export type TenantHostOptions = HostProbes & {
36
+ /** Lowercase hostname, already validated. */
37
+ host: string;
38
+ /** The receipt to compare against. Without one, records are checked for presence only. */
39
+ receipt?: RegistrationReceipt;
40
+ /**
41
+ * Why there is no receipt, as a short phrase ("no admin key to read it"), so the report says so
42
+ * instead of quietly comparing nothing.
43
+ */
44
+ receiptNote?: string;
45
+ };
46
+ export declare function runTenantHostChecks(options: TenantHostOptions): Promise<DoctorCheck[]>;
47
+ /** The certificate a server presents, whether or not Node trusts it. */
48
+ export declare const probeTls: TlsProbe;
49
+ export declare function describePeer(cert: PeerCertificate, authorized: boolean, authorizationError?: string): TlsPeer;
@@ -0,0 +1,367 @@
1
+ import { resolveCname, resolveTxt } from "node:dns/promises";
2
+ import { connect as tlsConnect } from "node:tls";
3
+ import { fetchOrNull, unauthenticatedInitialize } from "./checks.js";
4
+ import { customDomainState, defaultPublicPath, setDomainCommandLine, summarizeCustomDomain, } from "./custom-domain.js";
5
+ import { publicResourceUrl } from "./resource-urls.js";
6
+ export const systemDnsResolver = { resolveCname, resolveTxt };
7
+ const tlsTimeoutMs = 15_000;
8
+ const httpsPort = 443;
9
+ const msPerDay = 86_400_000;
10
+ /**
11
+ * Renewal is automatic once the _acme-challenge CNAME validates, and starts well before expiry. A
12
+ * certificate this close to the end means it has been failing to renew for weeks.
13
+ */
14
+ const certExpiryFailDays = 7;
15
+ const prmRootPath = "/.well-known/oauth-protected-resource";
16
+ export async function runTenantHostChecks(options) {
17
+ const host = options.host;
18
+ const dns = options.dns ?? systemDnsResolver;
19
+ const tls = options.tls ?? probeTls;
20
+ const now = options.now ?? (() => new Date());
21
+ // A receipt registered on a different host says nothing about this one, so it supplies no
22
+ // expectations; the first check reports the mismatch.
23
+ const receipt = options.receipt?.public_host?.trim().toLowerCase() === host ? options.receipt : undefined;
24
+ const expected = expectations(host, receipt);
25
+ const note = options.receiptNote ?? "no receipt for this host";
26
+ const checks = [checkRegistration(host, options.receipt, receipt, options.receiptNote)];
27
+ checks.push(...(await checkDns(host, expected, dns, note)));
28
+ const handshake = await checkTls(host, tls, now, receipt);
29
+ checks.push(handshake.check);
30
+ checks.push(await checkHttpRedirect(host, expected.path));
31
+ // Fetch refuses an untrusted certificate, so with a failed handshake every https check below
32
+ // would fail with the same unhelpful "fetch failed". The TLS check already says why.
33
+ if (!handshake.usable) {
34
+ checks.push({
35
+ name: "host https checks",
36
+ pass: true,
37
+ skipped: true,
38
+ detail: `Skipped the PRM and 401 checks: the TLS check failed, so https requests to ${host} would ` +
39
+ "fail with misleading errors.",
40
+ });
41
+ return checks;
42
+ }
43
+ checks.push(await checkPrm("path", `https://${host}${prmRootPath}${expected.path}`, expected.resource), await checkPrm("root", `https://${host}${prmRootPath}`, expected.resource), await checkChallenge(host, expected.path));
44
+ return checks;
45
+ }
46
+ function expectations(host, receipt) {
47
+ const path = receipt?.public_path?.trim() || defaultPublicPath;
48
+ const records = receipt?.custom_domain?.records ?? [];
49
+ return {
50
+ path,
51
+ resource: receipt?.resource?.trim() || publicResourceUrl(host, path),
52
+ fromReceipt: receipt !== undefined,
53
+ state: receipt ? customDomainState(receipt) : undefined,
54
+ cnameTarget: receipt?.custom_domain?.cname_target?.trim() ||
55
+ records.find((record) => record.purpose === "traffic")?.value,
56
+ ownership: records.find((record) => record.purpose === "ownership"),
57
+ certificate: records.find((record) => record.purpose === "certificate"),
58
+ };
59
+ }
60
+ function checkRegistration(host, given, matched, note) {
61
+ const name = "host custom domain";
62
+ if (!given) {
63
+ return skip(name, `No receipt for ${host} (${note ?? "none was supplied"}), so the expected DNS targets are ` +
64
+ "unknown: records are checked for presence only, not compared.");
65
+ }
66
+ const slug = given.slug ?? "<slug>";
67
+ if (!matched) {
68
+ const registeredOn = given.public_host ? `'${given.public_host}'` : "the gateway host";
69
+ return fail(name, `Resource '${slug}' is registered on ${registeredOn}, not on ${host}.`, `Check --host, or point the resource at this host: authflow set-domain --slug ${slug} --host ${host}`);
70
+ }
71
+ const summary = summarizeCustomDomain(matched);
72
+ if (customDomainState(matched) === "ready") {
73
+ return pass(name, `${host} is registered on '${slug}': ${summary}.`);
74
+ }
75
+ return fail(name, `${host} is registered on '${slug}', but the rail reports ${summary}.`, `Finish the DNS records, then have the rail re-check: ${setDomainCommandLine(slug)}`);
76
+ }
77
+ async function checkDns(host, expected, dns, note) {
78
+ return [
79
+ await checkTrafficCname(host, expected, dns, note),
80
+ await checkCertificateCname(host, expected, dns, note),
81
+ await checkOwnershipTxt(host, expected, dns, note),
82
+ ];
83
+ }
84
+ async function checkTrafficCname(host, expected, dns, note) {
85
+ const name = "host DNS: traffic CNAME";
86
+ const lookup = await lookupCname(dns, host);
87
+ if (lookup.kind === "error") {
88
+ return dnsLookupFailed(name, `CNAME for ${host}`, lookup.message);
89
+ }
90
+ if (lookup.kind === "missing") {
91
+ return fail(name, `${host} has no CNAME record.`, `Add a CNAME for ${host} pointing at ${expected.cnameTarget ?? "the cname_target in your receipt (authflow status shows it)"}. ` +
92
+ "Until it points at the edge, traffic never reaches Authflow.");
93
+ }
94
+ const target = expected.cnameTarget ? normalizeDnsName(expected.cnameTarget) : undefined;
95
+ if (!target) {
96
+ return skip(name, `${host} CNAME -> ${lookup.values[0]}. Not compared: ${note}.`);
97
+ }
98
+ if (lookup.values.includes(target)) {
99
+ return pass(name, `${host} CNAME -> ${target}.`);
100
+ }
101
+ return fail(name, `${host} CNAME points at ${lookup.values.join(", ")}, expected ${target}.`, `Change the CNAME for ${host} to ${target}. A CNAME to anywhere else sends traffic past Authflow.`);
102
+ }
103
+ async function checkCertificateCname(host, expected, dns, note) {
104
+ const name = "host DNS: _acme-challenge CNAME";
105
+ const record = expected.certificate;
106
+ const recordName = record?.name ?? `_acme-challenge.${host}`;
107
+ // The rail issues this record only once provisioning has begun. Until then there is nothing for
108
+ // the tenant to have added, and calling its absence a failure would send them hunting for it.
109
+ if (expected.fromReceipt && !record) {
110
+ return skip(name, `The rail has not issued a certificate record yet (state: ${expected.state ?? "unknown"}), so there is nothing to check.`);
111
+ }
112
+ const lookup = await lookupCname(dns, recordName);
113
+ if (lookup.kind === "error") {
114
+ return dnsLookupFailed(name, `CNAME for ${recordName}`, lookup.message);
115
+ }
116
+ if (lookup.kind === "missing") {
117
+ return fail(name, `${recordName} has no CNAME record.`, record
118
+ ? `Add CNAME ${recordName} -> ${record.value}. Without it the certificate cannot be validated or renewed.`
119
+ : "Add the certificate CNAME the rail issued (authflow status lists it). Without it the certificate " +
120
+ "cannot be validated or renewed.");
121
+ }
122
+ if (!record) {
123
+ return skip(name, `${recordName} CNAME -> ${lookup.values[0]}. Not compared: ${note}.`);
124
+ }
125
+ const target = normalizeDnsName(record.value);
126
+ if (lookup.values.includes(target)) {
127
+ return pass(name, `${recordName} CNAME -> ${target}.`);
128
+ }
129
+ return fail(name, `${recordName} CNAME points at ${lookup.values.join(", ")}, expected ${target}.`, `Change it to ${target}, exactly as the rail issued it.`);
130
+ }
131
+ async function checkOwnershipTxt(host, expected, dns, note) {
132
+ const name = "host DNS: _authflow-verify TXT";
133
+ const record = expected.ownership;
134
+ const recordName = record?.name ?? `_authflow-verify.${host}`;
135
+ let values;
136
+ try {
137
+ values = (await dns.resolveTxt(recordName)).map((chunks) => chunks.join(""));
138
+ }
139
+ catch (err) {
140
+ if (!isMissingRecord(err)) {
141
+ return dnsLookupFailed(name, `TXT for ${recordName}`, errorMessage(err));
142
+ }
143
+ values = [];
144
+ }
145
+ if (values.length === 0) {
146
+ return fail(name, `${recordName} has no TXT record.`, record
147
+ ? `Add TXT ${recordName} = ${record.value}. It proves you own the host, and the rail will not provision it without.`
148
+ : "Add the ownership TXT record the rail issued (authflow status lists it).");
149
+ }
150
+ if (record) {
151
+ return values.some((value) => value.trim() === record.value.trim())
152
+ ? pass(name, `${recordName} TXT matches the receipt.`)
153
+ : fail(name, `${recordName} has TXT ${values.map((value) => `'${value}'`).join(", ")}, expected '${record.value}'.`, `Replace it with exactly ${record.value}.`);
154
+ }
155
+ return values.some((value) => value.trim().startsWith("authflow-verify="))
156
+ ? skip(name, `${recordName} has an authflow-verify= TXT record. Not compared: ${note}.`)
157
+ : fail(name, `${recordName} has TXT records, but none starts with authflow-verify=.`, "Add the ownership TXT record the rail issued (authflow status lists it).");
158
+ }
159
+ async function lookupCname(dns, name) {
160
+ try {
161
+ const values = (await dns.resolveCname(name)).map(normalizeDnsName);
162
+ return values.length > 0 ? { kind: "found", values } : { kind: "missing" };
163
+ }
164
+ catch (err) {
165
+ return isMissingRecord(err) ? { kind: "missing" } : { kind: "error", message: errorMessage(err) };
166
+ }
167
+ }
168
+ function dnsLookupFailed(name, what, message) {
169
+ return fail(name, `Looking up the ${what} failed: ${message}`, "This is a resolver problem, not proof the record is missing. Retry, or check the domain's nameservers.");
170
+ }
171
+ /** NXDOMAIN and "name exists, no such record" both mean the record is not there. */
172
+ function isMissingRecord(err) {
173
+ const code = err?.code;
174
+ return code === "ENODATA" || code === "ENOTFOUND";
175
+ }
176
+ function normalizeDnsName(value) {
177
+ return value.trim().toLowerCase().replace(/\.$/, "");
178
+ }
179
+ // ---------------------------------------------------------------------------------------------
180
+ // TLS
181
+ // ---------------------------------------------------------------------------------------------
182
+ async function checkTls(host, probe, now, receipt) {
183
+ const name = "host TLS certificate";
184
+ let peer;
185
+ try {
186
+ peer = await probe(host, httpsPort);
187
+ }
188
+ catch (err) {
189
+ return {
190
+ usable: false,
191
+ check: fail(name, `Could not complete a TLS handshake with ${host}:${httpsPort}: ${errorMessage(err)}`, "Either DNS does not point at the edge yet, or the edge has no certificate for the host. " +
192
+ "Check the DNS results above, then wait for the certificate."),
193
+ };
194
+ }
195
+ const days = Math.floor((peer.validTo.getTime() - now().getTime()) / msPerDay);
196
+ const expiry = describeExpiry(peer.validTo, days);
197
+ const who = `issuer ${peer.issuer}, subject ${peer.subject}`;
198
+ if (!peer.authorized) {
199
+ return {
200
+ usable: false,
201
+ check: fail(name, `The certificate ${host} presents is not trusted: ${peer.authorizationError ?? "unknown error"} (${who}; expires ${expiry}).`, tlsRemedy(host, peer.authorizationError, receipt)),
202
+ };
203
+ }
204
+ if (days < certExpiryFailDays) {
205
+ return {
206
+ usable: true,
207
+ check: fail(name, `The certificate is about to expire (${who}; expires ${expiry}).`, "Renewal is automatic once the _acme-challenge CNAME validates. A certificate this close to " +
208
+ "expiry means that record is missing or wrong."),
209
+ };
210
+ }
211
+ return { usable: true, check: pass(name, `Issued by ${peer.issuer} to ${peer.subject}; expires ${expiry}.`) };
212
+ }
213
+ /** `2027-01-01, in 89 days` or, for a lapsed certificate, `2026-01-01, 3 days ago`. */
214
+ function describeExpiry(validTo, days) {
215
+ const date = validTo.toISOString().slice(0, 10);
216
+ const unit = Math.abs(days) === 1 ? "day" : "days";
217
+ return days >= 0 ? `${date}, in ${days} ${unit}` : `${date}, ${-days} ${unit} ago`;
218
+ }
219
+ function tlsRemedy(host, code, receipt) {
220
+ const recheck = receipt ? ` Then: ${setDomainCommandLine(receipt.slug ?? "<slug>")}` : "";
221
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID") {
222
+ return (`The edge answered with a certificate that does not cover ${host}. Its certificate for the host ` +
223
+ `is not active yet, or the traffic CNAME does not point at the edge. Check the DNS results above.${recheck}`);
224
+ }
225
+ if (code === "CERT_HAS_EXPIRED") {
226
+ return "The certificate has expired. Renewal is automatic once the _acme-challenge CNAME validates; check that record.";
227
+ }
228
+ return `Clients will refuse this certificate. Check the DNS results above, then wait for the edge to issue one.${recheck}`;
229
+ }
230
+ /** The certificate a server presents, whether or not Node trusts it. */
231
+ export const probeTls = (host, port) => new Promise((resolve, reject) => {
232
+ // Untrusted certificates are the finding here, not a reason to refuse the connection.
233
+ const socket = tlsConnect({ host, port, servername: host, rejectUnauthorized: false });
234
+ socket.setTimeout(tlsTimeoutMs, () => {
235
+ socket.destroy(new Error(`timed out after ${tlsTimeoutMs / 1000}s`));
236
+ });
237
+ socket.once("error", reject);
238
+ socket.once("secureConnect", () => {
239
+ try {
240
+ resolve(describePeer(socket.getPeerCertificate(), socket.authorized, socket.authorized ? undefined : String(socket.authorizationError)));
241
+ }
242
+ catch (err) {
243
+ reject(err);
244
+ }
245
+ finally {
246
+ socket.end();
247
+ }
248
+ });
249
+ });
250
+ export function describePeer(cert, authorized, authorizationError) {
251
+ const validTo = new Date(cert.valid_to);
252
+ if (!cert.valid_to || Number.isNaN(validTo.getTime())) {
253
+ throw new Error("the server presented no usable certificate");
254
+ }
255
+ return {
256
+ authorized,
257
+ authorizationError,
258
+ issuer: describeName(cert.issuer),
259
+ subject: describeName(cert.subject),
260
+ validTo,
261
+ };
262
+ }
263
+ /** `Let's Encrypt (E8)`: organisation and common name, whichever the certificate has. */
264
+ function describeName(name) {
265
+ const first = (value) => {
266
+ const single = Array.isArray(value) ? value[0] : value;
267
+ return typeof single === "string" && single.length > 0 ? single : undefined;
268
+ };
269
+ const organisation = first(name?.O);
270
+ const common = first(name?.CN);
271
+ if (organisation && common) {
272
+ return `${organisation} (${common})`;
273
+ }
274
+ return common ?? organisation ?? "unknown";
275
+ }
276
+ // ---------------------------------------------------------------------------------------------
277
+ // HTTP
278
+ // ---------------------------------------------------------------------------------------------
279
+ async function checkHttpRedirect(host, path) {
280
+ const name = "host http redirects to https";
281
+ const url = `http://${host}${path}`;
282
+ const response = await fetchOrNull(url, { redirect: "manual" });
283
+ if (!response.ok) {
284
+ return fail(name, `GET ${url} could not be reached: ${response.error}`, "Port 80 is not answering. The edge redirects plain http for proxied hostnames, so the traffic " +
285
+ "CNAME is probably not in place yet.");
286
+ }
287
+ const status = response.status ?? 0;
288
+ if ([301, 302, 303, 307, 308].includes(status)) {
289
+ const target = parseUrl(response.location ?? "", url);
290
+ return target?.protocol === "https:" && target.host === host
291
+ ? pass(name, `GET ${url} returned ${status} to ${response.location}.`)
292
+ : fail(name, `GET ${url} returned ${status} to '${response.location ?? ""}', not to https://${host}.`, "The redirect has to stay on the tenant host and switch to https, or a client following it " +
293
+ "connects somewhere else or stays unencrypted.");
294
+ }
295
+ return fail(name, `GET ${url} returned ${status} over plain http instead of redirecting to https.`, "Enable Always Use HTTPS on the edge zone. Without it, a client configured with an http:// URL " +
296
+ "talks to the server unencrypted.");
297
+ }
298
+ async function checkPrm(label, url, expectedResource) {
299
+ const name = `host PRM (${label})`;
300
+ const response = await fetchOrNull(url);
301
+ if (!response.ok) {
302
+ return fail(name, `GET ${url} could not be reached: ${response.error}`, "Check the host's DNS and certificate above.");
303
+ }
304
+ if (response.status !== 200) {
305
+ return fail(name, `GET ${url} returned ${response.status}, expected 200.`, response.status === 404
306
+ ? "The host answers but the rail does not recognise it: the custom domain is not ready, or the " +
307
+ "edge is not forwarding the host. Clients discover the authorization server here (Claude Code " +
308
+ "probes the root path)."
309
+ : "Clients discover the authorization server from this document. File a rail bug.");
310
+ }
311
+ let resource;
312
+ try {
313
+ resource = JSON.parse(response.body ?? "").resource;
314
+ }
315
+ catch {
316
+ return fail(name, `${url} did not return JSON.`, "Something other than the rail is answering on the host.");
317
+ }
318
+ if (resource !== expectedResource) {
319
+ return fail(name, `${url} names resource '${String(resource)}', expected '${expectedResource}'.`, "Tokens are audience-bound to this value. A client that connected at the host would be refused at " +
320
+ "/authorize as invalid_target.");
321
+ }
322
+ return pass(name, `GET ${url} returned 200 with resource ${resource}.`);
323
+ }
324
+ async function checkChallenge(host, path) {
325
+ const name = "host 401 challenge";
326
+ const url = `https://${host}${path}`;
327
+ const response = await fetchOrNull(url, unauthenticatedInitialize());
328
+ if (!response.ok) {
329
+ return fail(name, `Unauthenticated POST ${url} could not be reached: ${response.error}`, "Check the host's DNS and certificate above.");
330
+ }
331
+ if (response.status !== 401) {
332
+ return fail(name, `Unauthenticated POST ${url} returned ${response.status}, expected 401.`, response.status === 404
333
+ ? "The host answers but the rail does not recognise it: the custom domain is not ready, or the edge is not forwarding the host."
334
+ : "An unauthenticated connect that does not 401 means clients will never start OAuth.");
335
+ }
336
+ const challenge = response.headers?.get("www-authenticate") ?? "";
337
+ const metadata = resourceMetadataValue(challenge);
338
+ if (!metadata) {
339
+ return fail(name, `401 returned, but WWW-Authenticate has no resource_metadata parameter: '${challenge}'.`, "MCP clients cannot discover the authorization server. File a rail bug.");
340
+ }
341
+ const target = parseUrl(metadata);
342
+ if (target?.protocol !== "https:" || target.host !== host) {
343
+ return fail(name, `The 401's resource_metadata is '${metadata}', which is not an https URL on ${host}.`, "Clients fetch protected resource metadata from this URL. It has to name the host they connected " +
344
+ "to; pointing it at the gateway host sends them to the wrong audience.");
345
+ }
346
+ return pass(name, `401 with resource_metadata=${metadata}.`);
347
+ }
348
+ /** The `resource_metadata` parameter of a Bearer challenge, quoted or bare. */
349
+ function resourceMetadataValue(challenge) {
350
+ const match = /resource_metadata\s*=\s*(?:"([^"]*)"|([^\s,]+))/i.exec(challenge);
351
+ return match?.[1] ?? match?.[2] ?? undefined;
352
+ }
353
+ function parseUrl(value, base) {
354
+ try {
355
+ return new URL(value, base);
356
+ }
357
+ catch {
358
+ return undefined;
359
+ }
360
+ }
361
+ function errorMessage(err) {
362
+ return err instanceof Error ? err.message : String(err);
363
+ }
364
+ const pass = (name, detail) => ({ name, pass: true, detail });
365
+ const skip = (name, detail) => ({ name, pass: true, skipped: true, detail });
366
+ const fail = (name, detail, remedy) => ({ name, pass: false, detail, remedy });
367
+ //# sourceMappingURL=tenant-host.js.map