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.
- package/README.md +157 -5
- package/dist/checks.d.ts +30 -0
- package/dist/checks.js +36 -0
- package/dist/checks.js.map +1 -0
- package/dist/client-conformance.d.ts +5 -1
- package/dist/client-conformance.js +9 -1
- package/dist/client-conformance.js.map +1 -1
- package/dist/commands/doctor.js +5 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/register.js +30 -2
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/set-domain.d.ts +34 -0
- package/dist/commands/set-domain.js +162 -0
- package/dist/commands/set-domain.js.map +1 -0
- package/dist/config.d.ts +32 -1
- package/dist/config.js.map +1 -1
- package/dist/custom-domain.d.ts +96 -0
- package/dist/custom-domain.js +289 -0
- package/dist/custom-domain.js.map +1 -0
- package/dist/doctor.d.ts +31 -23
- package/dist/doctor.js +271 -57
- package/dist/doctor.js.map +1 -1
- package/dist/google-redirect.d.ts +5 -1
- package/dist/google-redirect.js +11 -5
- package/dist/google-redirect.js.map +1 -1
- package/dist/mcp/server.d.ts +8 -1
- package/dist/mcp/server.js +137 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/program.js +4 -1
- package/dist/program.js.map +1 -1
- package/dist/rail.d.ts +16 -0
- package/dist/rail.js +39 -0
- package/dist/rail.js.map +1 -1
- package/dist/resource-urls.d.ts +27 -0
- package/dist/resource-urls.js +56 -0
- package/dist/resource-urls.js.map +1 -0
- package/dist/status.d.ts +15 -1
- package/dist/status.js +59 -5
- package/dist/status.js.map +1 -1
- package/dist/surface.d.ts +1 -0
- package/dist/surface.js +1 -0
- package/dist/surface.js.map +1 -1
- package/dist/tenant-host.d.ts +49 -0
- package/dist/tenant-host.js +367 -0
- package/dist/tenant-host.js.map +1 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +27 -0
- package/dist/version.js.map +1 -0
- 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
|
-
//
|
|
81
|
-
//
|
|
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:
|
|
86
|
-
detail:
|
|
87
|
-
|
|
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
|
}
|
package/dist/status.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"
|
|
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",
|
package/dist/surface.js.map
CHANGED
|
@@ -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
|