metergraph-cli 0.0.0-stage → 0.2.0-preview.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.
@@ -0,0 +1,449 @@
1
+ import path from "node:path";
2
+
3
+ import { discover, normalizeUuid, verifyContext } from "./auth-oauth.js";
4
+ import { AGENT_CONTRACT_VERSION, CONNECTION_GUIDE_URL, MAX_BODY_BYTES, METADATA_SCOPE } from "./constants.js";
5
+ import { CREDENTIAL_MIN_LENGTH, readCredentialFile } from "./deployment-credential.js";
6
+ import { checkDeployment } from "./doctor.js";
7
+ import { parseJsonObject } from "./http.js";
8
+ import { parseOrigin } from "./origin.js";
9
+ import { capabilitySummary } from "./read-contract.js";
10
+ import { deadline, send } from "./transport.js";
11
+
12
+ // Internal deployment routing for customer-operated Metergraph deployments.
13
+ // planDeploymentRoute is pure: it checks enumerated inputs and reports which
14
+ // prerequisites stand between the caller and a Metadata-only agent
15
+ // connection. verifyDeploymentRoute then proves the route afresh against the
16
+ // service. Neither installs, provisions, registers or writes anything, and
17
+ // results hold only fixed tokens, the normalized origin and workspace id, and
18
+ // statuses: no raw input, response text or credential.
19
+ //
20
+ // Every result is { ok, outcome, reason, plan, verification, receipt }, and
21
+ // every outcome is one of the CLI's EXIT_CODES outcomes. A result produced
22
+ // after the credential file was read also carries a non-enumerable
23
+ // knownCredentials array, so a caller that prints it can apply the same
24
+ // known-credential suppression as other commands. It is never serialized.
25
+
26
+ const PREREQUISITES = Object.freeze({
27
+ // A customer machine running the released commercial bundle.
28
+ "customer-local": Object.freeze([
29
+ "released_signed_bundle",
30
+ "registry_invitation",
31
+ "bundle_started_verified",
32
+ "local_admin_configured",
33
+ "metadata_agent_credential",
34
+ ]),
35
+ // A deployment an operator provisions in the customer's own cloud.
36
+ byoc: Object.freeze([
37
+ "operator_provisioning",
38
+ "private_network_reachability",
39
+ "identity_membership_configured",
40
+ "metadata_agent_credential",
41
+ ]),
42
+ // The self-hosted open source server. It has no hosted sign up, keys page
43
+ // or registry. Installing a server distribution, the server serving
44
+ // deployment discovery and advertising the Metadata scope, ingestion tokens
45
+ // (MG_TOKENS) and agent read tokens (MG_AGENT_TOKENS) are each separate
46
+ // operator steps. A server without deployment discovery and exact Metadata
47
+ // support requires an operator upgrade or configuration handoff.
48
+ oss: Object.freeze([
49
+ "server_distribution_installed",
50
+ "deployment_discovery_supported",
51
+ "metadata_scope_supported",
52
+ "ingestion_tokens_configured",
53
+ "agent_read_tokens_configured",
54
+ "metadata_agent_credential",
55
+ ]),
56
+ });
57
+
58
+ // Prerequisites only the server operator can meet, by upgrading or
59
+ // configuring the server. They get an operator handoff.
60
+ const OPERATOR_PREREQUISITES = Object.freeze(["deployment_discovery_supported", "metadata_scope_supported"]);
61
+
62
+ // Each model expects exactly one profile. oss is never treated as local or
63
+ // any other profile.
64
+ const PROFILES = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
65
+
66
+ // Only a shell on the customer's own machine can reach a local deployment and
67
+ // read its private credential file. Hosted agent runtimes and remote shells
68
+ // get a handoff; nothing is tunnelled, forwarded or relaxed for them.
69
+ const RUNTIMES = Object.freeze(["local"]);
70
+ const HANDOFF_RUNTIMES = Object.freeze(["cloud", "cloud-no-shell", "remote-ssh"]);
71
+ const STATUSES = Object.freeze(["ready", "required", "unknown"]);
72
+
73
+ // The only next action kinds a result can carry.
74
+ const ACTION_KINDS = Object.freeze([
75
+ "verify_route",
76
+ "complete_prerequisite",
77
+ "oss_operator_handoff",
78
+ "run_on_customer_machine",
79
+ "use_https_origin",
80
+ "connection_guide",
81
+ "check_private_network",
82
+ "platform_credential_handoff",
83
+ "fix_credential_file",
84
+ "use_metadata_only_credential",
85
+ "retry",
86
+ ]);
87
+
88
+ // Facts agent verification says nothing about. They are reported as not
89
+ // checked on every verification and never inferred from it.
90
+ const NOT_CHECKED = Object.freeze(["ingest", "provider", "sdk", "registration", "bundle"]);
91
+
92
+ // Receipts record how far a previous run got. They are context only: a
93
+ // receipt never authorizes anything or skips a check.
94
+ // prerequisites: every prerequisite was reported ready
95
+ // preflight: the service reported the expected deployment profile and
96
+ // advertised the Metadata scope on its fixed OAuth metadata paths
97
+ // verified: the Metadata credential was verified for the workspace
98
+ const RECEIPT_VERSION = 1;
99
+ const PHASES = Object.freeze(["prerequisites", "preflight", "verified"]);
100
+ const RECEIPT_KEYS = Object.freeze([
101
+ "version",
102
+ "model",
103
+ "runtime",
104
+ "origin",
105
+ "workspace_id",
106
+ "deployment_profile",
107
+ "phase",
108
+ ]);
109
+
110
+ // Returned instead of any result that would contain the credential. The
111
+ // fallback, used when the credential happens to contain the first refusal's
112
+ // own text, uses only strings shorter than any credential, including its keys.
113
+ const ECHO_REFUSAL = Object.freeze(["verification_failed", "credential_in_metadata_response"]);
114
+ const ECHO_FALLBACK = Object.freeze(["unsupported", "credential_echo"]);
115
+
116
+ const DEPLOYMENT_PATH = "/v1/deployment";
117
+ const MIN_TIMEOUT_MS = 100;
118
+ const MAX_TIMEOUT_MS = 60000;
119
+
120
+ function action(kind, prerequisite = null) {
121
+ // Kinds are constants from this module, so this is a programming error.
122
+ if (!ACTION_KINDS.includes(kind)) throw new Error("unknown next action kind");
123
+ return { kind, prerequisite, url: CONNECTION_GUIDE_URL };
124
+ }
125
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
126
+ const result = (ok, outcome, reason, { plan = null, verification = null, receipt = null } = {}) => ({
127
+ ok,
128
+ outcome,
129
+ reason,
130
+ plan,
131
+ verification,
132
+ receipt,
133
+ });
134
+ const invalid = (reason) => result(false, "invalid_input", reason);
135
+
136
+ export function planDeploymentRoute(input) {
137
+ const context = routeContext(input);
138
+ if (!context.ok) return invalid(context.reason);
139
+ const { model, runtime, origin, workspaceId, profile, statuses } = context;
140
+
141
+ const plan = {
142
+ model,
143
+ runtime,
144
+ origin,
145
+ workspace_id: workspaceId,
146
+ deployment_profile: profile,
147
+ prerequisites: PREREQUISITES[model].map((name) => ({ name, status: statuses[name] })),
148
+ next_action: null,
149
+ unsupported_reasons: [],
150
+ };
151
+
152
+ if (HANDOFF_RUNTIMES.includes(runtime)) plan.unsupported_reasons.push("runtime_not_customer_machine");
153
+ if (model === "byoc" && !origin.startsWith("https:")) plan.unsupported_reasons.push("byoc_requires_https");
154
+ if (plan.unsupported_reasons.length > 0) {
155
+ plan.next_action = action(
156
+ HANDOFF_RUNTIMES.includes(runtime) ? "run_on_customer_machine" : "use_https_origin",
157
+ );
158
+ return result(false, "unsupported", plan.unsupported_reasons[0], { plan });
159
+ }
160
+
161
+ const pending = plan.prerequisites.find((entry) => entry.status !== "ready");
162
+ if (pending !== undefined) {
163
+ // A handoff only: the person or operator completes it. Nothing is
164
+ // installed, provisioned or requested on their behalf.
165
+ const kind = OPERATOR_PREREQUISITES.includes(pending.name) ? "oss_operator_handoff" : "complete_prerequisite";
166
+ plan.next_action = action(kind, pending.name);
167
+ const reason = pending.status === "required" ? "prerequisite_required" : "prerequisite_unknown";
168
+ plan.unsupported_reasons.push(reason);
169
+ return result(false, "unsupported", reason, { plan });
170
+ }
171
+
172
+ plan.next_action = action("verify_route");
173
+ return result(true, "ok", "prerequisites_ready", { plan, receipt: receiptFor(plan, "prerequisites") });
174
+ }
175
+
176
+ // Proves the route with fresh requests, whatever a previous receipt says:
177
+ // 1. GET /v1/deployment on the origin with no credential. A redirect,
178
+ // missing endpoint, unexpected or mismatched profile, or an unreachable
179
+ // or unhealthy service stops here.
180
+ // 2. OAuth discovery with no credential, through auth-oauth discover: the
181
+ // protected resource and authorization server metadata must name the
182
+ // fixed resource, issuer and endpoints on this origin, with no redirect,
183
+ // and advertise the Metadata scope. Nothing is registered. There is no
184
+ // weaker fallback.
185
+ // 3. Only then reads the Metadata credential from credentialFile.
186
+ // 4. GET /v1/agent/workspace and /v1/agent/capabilities with it, through
187
+ // verifyContext, then the full capability summary and a check that no
188
+ // capability that reads content, replays, calls a provider or mutates is
189
+ // available, including ones this CLI does not know.
190
+ // One deadline of timeoutMs and the optional cancel signal span every step,
191
+ // and are checked after every request and immediately before success. No
192
+ // ingest, evaluation, provider or trace request is ever made, and the
193
+ // credential is never returned. A broader key fails closed: there is no
194
+ // fallback to Debug or Replay access.
195
+ export async function verifyDeploymentRoute(input) {
196
+ const planned = planDeploymentRoute(input);
197
+ if (!planned.ok) return planned;
198
+ const { plan } = planned;
199
+ const { credentialFile, timeoutMs = 15000, cancel = null } = input;
200
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) {
201
+ return invalid("timeout_invalid");
202
+ }
203
+ if (cancel !== null && !(cancel instanceof AbortSignal)) return invalid("cancel_invalid");
204
+ if (typeof credentialFile !== "string" || !path.isAbsolute(credentialFile)) {
205
+ return invalid("credential_file_invalid");
206
+ }
207
+
208
+ const verification = {
209
+ deployment_discovery: "not_run",
210
+ metadata_discovery: "not_run",
211
+ credential_file: "not_run",
212
+ agent_metadata_access: "not_run",
213
+ contract: null,
214
+ access_scope: null,
215
+ content_included: null,
216
+ capabilities: null,
217
+ not_checked: [...NOT_CHECKED],
218
+ next_action: null,
219
+ };
220
+ let phase = "prerequisites";
221
+ // The step in progress, marked interrupted if the run stops during it.
222
+ let step = "deployment_discovery";
223
+ let token = null;
224
+ const { signal, timedOut } = deadline(timeoutMs, cancel);
225
+ const stop = (outcome, reason, kind = "connection_guide") => {
226
+ verification.next_action = action(kind);
227
+ plan.next_action = verification.next_action;
228
+ if (outcome === "unsupported") plan.unsupported_reasons.push(reason);
229
+ const stopped = result(false, outcome, reason, { plan, verification, receipt: receiptFor(plan, phase) });
230
+ return token === null ? stopped : withoutCredential(stopped, token);
231
+ };
232
+ const fail = (outcome, reason, kind) => {
233
+ verification[step] = "failed";
234
+ return stop(outcome, reason, kind);
235
+ };
236
+ const interrupted = () => {
237
+ verification[step] = "interrupted";
238
+ if (timedOut()) return stop("connection_failed", "timeout");
239
+ return stop("cancelled", "cancelled", "retry");
240
+ };
241
+ // Handoff for a discovery step the service does not support.
242
+ const unsupportedKind = (outcome) => {
243
+ if (plan.model === "oss" && outcome === "unsupported") return "oss_operator_handoff";
244
+ if (plan.model === "byoc" && outcome === "connection_failed") return "check_private_network";
245
+ return "connection_guide";
246
+ };
247
+
248
+ if (signal.aborted) return interrupted();
249
+ const response = await send(plan.origin, DEPLOYMENT_PATH, { signal, maxBytes: MAX_BODY_BYTES });
250
+ if (signal.aborted) return interrupted();
251
+ const discovered = discoveredProfile(response, plan.model);
252
+ if (discovered.outcome !== null) {
253
+ return fail(discovered.outcome, discovered.reason, unsupportedKind(discovered.outcome));
254
+ }
255
+ if (discovered.profile !== plan.deployment_profile) {
256
+ return fail("unsupported", "deployment_profile_mismatch", unsupportedKind("unsupported"));
257
+ }
258
+ verification.deployment_discovery = "passed";
259
+
260
+ step = "metadata_discovery";
261
+ const oauth = await discover(plan.origin, signal);
262
+ if (signal.aborted) return interrupted();
263
+ if (!oauth.ok) return fail(oauth.outcome, oauth.reason, unsupportedKind(oauth.outcome));
264
+ verification.metadata_discovery = "passed";
265
+ phase = "preflight";
266
+
267
+ step = "credential_file";
268
+ const credential = readCredentialFile(credentialFile);
269
+ if (!credential.ok) {
270
+ return fail(
271
+ credential.outcome,
272
+ credential.reason,
273
+ credential.outcome === "unsupported" ? "platform_credential_handoff" : "fix_credential_file",
274
+ );
275
+ }
276
+ // From here on every result passes through the known-credential guard.
277
+ token = credential.token;
278
+ verification.credential_file = "passed";
279
+ if (signal.aborted) return interrupted();
280
+
281
+ step = "agent_metadata_access";
282
+ const ctx = { profile: plan.deployment_profile, workspaceId: plan.workspace_id };
283
+ const verified = await verifyContext(plan.origin, token, ctx, signal);
284
+ if (signal.aborted) return interrupted();
285
+ if (!verified.ok) return fail(verified.outcome, verified.reason, "use_metadata_only_credential");
286
+ const summary = capabilitySummary(verified.documents.capabilities, ctx, new Set());
287
+ const refusal = summary.ok ? capabilityRefusal(summary.value, verified.documents.capabilities.agent) : summary;
288
+ if (refusal !== null) return fail(refusal.outcome, refusal.reason, "use_metadata_only_credential");
289
+
290
+ // Nothing is reported as verified after a cancellation or the deadline.
291
+ if (signal.aborted) return interrupted();
292
+ verification.agent_metadata_access = "passed";
293
+ // checkWorkspace already required exactly these values.
294
+ verification.contract = AGENT_CONTRACT_VERSION;
295
+ verification.access_scope = METADATA_SCOPE;
296
+ verification.content_included = false;
297
+ verification.capabilities = { workspace_context: true, capability_discovery: true };
298
+ phase = "verified";
299
+ const verifiedResult = result(true, "ok", "metadata_access_verified", {
300
+ plan,
301
+ verification,
302
+ receipt: receiptFor(plan, phase),
303
+ });
304
+ return withoutCredential(verifiedResult, token);
305
+ }
306
+
307
+ // A result that may be printed must not hold the credential anywhere, even
308
+ // where it only coincides with the origin, workspace id or a fixed string.
309
+ // Such a result is replaced by a minimal refusal with no plan, verification
310
+ // or receipt. Either way the credential is attached as non-enumerable
311
+ // knownCredentials for the caller's own output suppression.
312
+ function withoutCredential(value, token) {
313
+ let safe = value;
314
+ if (holdsKnown(safe, token)) {
315
+ safe = result(false, ...ECHO_REFUSAL);
316
+ if (holdsKnown(safe, token)) safe = result(false, ...ECHO_FALLBACK);
317
+ }
318
+ Object.defineProperty(safe, "knownCredentials", { value: Object.freeze([token]), enumerable: false });
319
+ return safe;
320
+ }
321
+
322
+ // True when any string in value, keys included, contains the credential.
323
+ function holdsKnown(value, token) {
324
+ if (typeof value === "string") return value.includes(token);
325
+ if (value === null || typeof value !== "object") return false;
326
+ for (const [key, entry] of Object.entries(value)) {
327
+ if (holdsKnown(key, token) || holdsKnown(entry, token)) return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ // The fallback refusal can never hold a credential.
333
+ for (const text of [...ECHO_FALLBACK, ...Object.keys(result(false, ...ECHO_FALLBACK))]) {
334
+ if (text.length >= CREDENTIAL_MIN_LENGTH) throw new Error("fallback refusal text could contain a credential");
335
+ }
336
+
337
+ // Checks every input against fixed enumerations. Rejected values are never
338
+ // echoed; the reason names only which input was wrong.
339
+ function routeContext(input) {
340
+ if (!isObject(input)) return { ok: false, reason: "input_invalid" };
341
+ const { model, runtime, prerequisites = {}, receipt = null } = input;
342
+ if (typeof model !== "string" || !Object.hasOwn(PROFILES, model)) return { ok: false, reason: "model_invalid" };
343
+ if (!RUNTIMES.includes(runtime) && !HANDOFF_RUNTIMES.includes(runtime)) {
344
+ return { ok: false, reason: "runtime_invalid" };
345
+ }
346
+ const origin = parseOrigin(input.origin);
347
+ if (origin === null) return { ok: false, reason: "origin_invalid" };
348
+ const workspaceId = normalizeUuid(input.workspaceId);
349
+ if (workspaceId === null) return { ok: false, reason: "workspace_id_invalid" };
350
+
351
+ if (!isObject(prerequisites)) return { ok: false, reason: "prerequisites_invalid" };
352
+ const names = PREREQUISITES[model];
353
+ const statuses = {};
354
+ for (const name of names) statuses[name] = "unknown";
355
+ for (const [name, status] of Object.entries(prerequisites)) {
356
+ if (!names.includes(name) || !STATUSES.includes(status)) return { ok: false, reason: "prerequisites_invalid" };
357
+ statuses[name] = status;
358
+ }
359
+
360
+ const context = { ok: true, model, runtime, origin, workspaceId, profile: PROFILES[model], statuses };
361
+ if (receipt !== null) {
362
+ const reason = receiptProblem(receipt, context);
363
+ if (reason !== null) return { ok: false, reason };
364
+ }
365
+ return context;
366
+ }
367
+
368
+ // A receipt must have exactly the documented keys, so one carrying a
369
+ // credential, path or anything else is refused, and must describe this exact
370
+ // route. Its phase is not used for anything.
371
+ function receiptProblem(receipt, context) {
372
+ if (!isObject(receipt)) return "receipt_invalid";
373
+ const keys = Object.keys(receipt);
374
+ if (keys.length !== RECEIPT_KEYS.length || !RECEIPT_KEYS.every((key) => Object.hasOwn(receipt, key))) {
375
+ return "receipt_invalid";
376
+ }
377
+ if (receipt.version !== RECEIPT_VERSION || !PHASES.includes(receipt.phase)) return "receipt_invalid";
378
+ for (const key of ["model", "runtime", "origin", "workspace_id", "deployment_profile"]) {
379
+ if (typeof receipt[key] !== "string") return "receipt_invalid";
380
+ }
381
+ if (
382
+ receipt.model !== context.model ||
383
+ receipt.runtime !== context.runtime ||
384
+ parseOrigin(receipt.origin) !== context.origin ||
385
+ normalizeUuid(receipt.workspace_id) !== context.workspaceId ||
386
+ receipt.deployment_profile !== context.profile
387
+ ) {
388
+ return "receipt_context_mismatch";
389
+ }
390
+ return null;
391
+ }
392
+
393
+ function receiptFor(plan, phase) {
394
+ return {
395
+ version: RECEIPT_VERSION,
396
+ model: plan.model,
397
+ runtime: plan.runtime,
398
+ origin: plan.origin,
399
+ workspace_id: plan.workspace_id,
400
+ deployment_profile: plan.deployment_profile,
401
+ phase,
402
+ };
403
+ }
404
+
405
+ // The doctor decision for GET /v1/deployment, which knows local and byoc-core,
406
+ // plus the oss profile, which only this module accepts. Returns
407
+ // { outcome: null, profile } or { outcome, reason }.
408
+ function discoveredProfile(response, model) {
409
+ const checked = checkDeployment(response);
410
+ if (checked.outcome === null) return { outcome: null, profile: checked.profile };
411
+ if (checked.reason === "deployment_endpoint_missing" && model === "oss") {
412
+ // This server has no deployment endpoint, and static agent tokens can
413
+ // carry broader scopes than Metadata. There is no
414
+ // fallback discovery, so the operator is handed off and no credential is
415
+ // read or sent.
416
+ return { outcome: "unsupported", reason: "oss_deployment_discovery_unavailable" };
417
+ }
418
+ if (checked.reason === "unrecognized_profile" && parseJsonObject(response)?.deployment_profile === "oss") {
419
+ return { outcome: null, profile: "oss" };
420
+ }
421
+ return { outcome: checked.outcome, reason: checked.reason };
422
+ }
423
+
424
+ // capabilitySummary refuses known capabilities that are not metadata-only and
425
+ // available. This also requires the two capabilities verification relies on,
426
+ // and refuses any available entry, under any name, that is not plainly
427
+ // metadata-only: no content, mutation, external (provider) calls, content or
428
+ // replay privacy class, or scope other than Metadata.
429
+ function capabilityRefusal(summary, agent) {
430
+ for (const name of ["workspace_context", "capability_discovery"]) {
431
+ if (summary.agent[name]?.available !== true) {
432
+ return { outcome: "verification_failed", reason: "required_capability_unavailable" };
433
+ }
434
+ }
435
+ for (const entry of Object.values(agent)) {
436
+ if (!isObject(entry) || typeof entry.available !== "boolean") {
437
+ return { outcome: "verification_failed", reason: "capabilities_response_invalid" };
438
+ }
439
+ if (!entry.available) continue;
440
+ const metadataOnly =
441
+ entry.content === false &&
442
+ entry.mutates === false &&
443
+ entry.external_calls === false &&
444
+ entry.privacy_class === "metadata" &&
445
+ entry.required_scope === METADATA_SCOPE;
446
+ if (!metadataOnly) return { outcome: "verification_failed", reason: "sensitive_capability_available" };
447
+ }
448
+ return null;
449
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,208 @@
1
+ import { CONNECTION_GUIDE_URL, SUPPORTED_PROFILES } from "./constants.js";
2
+ import { get, parseJsonObject } from "./http.js";
3
+
4
+ const CHECKS = Object.freeze([
5
+ { name: "health", path: "/healthz", readBody: true },
6
+ { name: "deployment", path: "/v1/deployment", readBody: true },
7
+ { name: "capabilities", path: "/v1/agent/capabilities", readBody: false },
8
+ ]);
9
+
10
+ // Runs the read-only connection probe against an already validated origin.
11
+ // Every request shares one deadline of timeoutMs. The result contains only
12
+ // fixed tokens, numeric HTTP statuses, the validated origin and a profile from
13
+ // SUPPORTED_PROFILES. Nothing from a response body or header is copied.
14
+ export async function runDoctor({ origin, timeoutMs }) {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ return await probe(origin, controller.signal);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+
24
+ async function probe(origin, signal) {
25
+ const report = {
26
+ origin,
27
+ reachable: null,
28
+ healthy: null,
29
+ deployment_profile: null,
30
+ profile_status: "unknown",
31
+ authentication_required: null,
32
+ authenticated: false,
33
+ workspace: null,
34
+ checks: CHECKS.map(({ name, path }) => ({
35
+ name,
36
+ path,
37
+ result: "skipped",
38
+ http_status: null,
39
+ reason: null,
40
+ })),
41
+ next_action: null,
42
+ };
43
+
44
+ for (const [index, check] of CHECKS.entries()) {
45
+ const response = await get(new URL(check.path, origin), {
46
+ signal,
47
+ readBody: check.readBody,
48
+ });
49
+ const entry = report.checks[index];
50
+ const verdict = evaluate(check.name, response, report);
51
+ entry.result = verdict.outcome === null || verdict.pass ? "pass" : "fail";
52
+ entry.http_status = response.status ?? null;
53
+ entry.reason = verdict.reason;
54
+ if (verdict.outcome !== null) {
55
+ return { outcome: verdict.outcome, reason: verdict.reason, report };
56
+ }
57
+ }
58
+
59
+ // Unreachable in this preview: the capabilities check always ends the probe.
60
+ return { outcome: "internal_error", reason: "probe_incomplete", report };
61
+ }
62
+
63
+ // The same decision doctor makes for one GET /v1/deployment response, for
64
+ // status. Returns { outcome, reason, profile } where outcome is null and
65
+ // profile is one of SUPPORTED_PROFILES when the service reported a supported
66
+ // profile.
67
+ export function checkDeployment(response) {
68
+ const report = { reachable: null, healthy: null, deployment_profile: null, profile_status: "unknown" };
69
+ const { outcome, reason } = evaluate("deployment", response, report);
70
+ return { outcome, reason, profile: report.deployment_profile };
71
+ }
72
+
73
+ // Returns { outcome, reason } where outcome is null when the probe should
74
+ // continue to the next check.
75
+ function evaluate(name, response, report) {
76
+ if (response.kind === "error") {
77
+ // Headers that arrived before a body failure still prove the server
78
+ // answered over HTTP. They prove nothing about health or authentication.
79
+ if (name === "health") report.reachable = response.status !== null;
80
+ return { outcome: "connection_failed", reason: response.reason };
81
+ }
82
+
83
+ report.reachable = true;
84
+ const { status } = response;
85
+
86
+ if (status >= 300 && status < 400) {
87
+ return { outcome: "redirect_rejected", reason: "redirect" };
88
+ }
89
+ if (status === 503) {
90
+ report.healthy = false;
91
+ return { outcome: "unhealthy", reason: "service_unavailable" };
92
+ }
93
+ if (status >= 500) {
94
+ report.healthy = false;
95
+ return { outcome: "unhealthy", reason: "server_error" };
96
+ }
97
+
98
+ if (name === "health") return evaluateHealth(response, report);
99
+ if (name === "deployment") return evaluateDeployment(response, report);
100
+ return evaluateCapabilities(response, report);
101
+ }
102
+
103
+ function evaluateHealth(response, report) {
104
+ if (response.status !== 200) {
105
+ return { outcome: "unsupported", reason: "unexpected_status" };
106
+ }
107
+ if (response.tooLarge) {
108
+ return { outcome: "unsupported", reason: "response_too_large" };
109
+ }
110
+ const body = parseJsonObject(response);
111
+ if (body === null || typeof body.ok !== "boolean") {
112
+ return { outcome: "unsupported", reason: "invalid_response" };
113
+ }
114
+ if (body.ok !== true) {
115
+ report.healthy = false;
116
+ return { outcome: "unhealthy", reason: "reported_unhealthy" };
117
+ }
118
+ report.healthy = true;
119
+ return { outcome: null, reason: null };
120
+ }
121
+
122
+ function evaluateDeployment(response, report) {
123
+ if (response.status === 404) {
124
+ // Servers without this endpoint, such as a self-hosted open source
125
+ // server, have no profile adapter yet. Never assume they are hosted.
126
+ report.profile_status = "unavailable";
127
+ return { outcome: "unsupported", reason: "deployment_endpoint_missing" };
128
+ }
129
+ if (response.status !== 200) {
130
+ return { outcome: "unsupported", reason: "unexpected_status" };
131
+ }
132
+ if (response.tooLarge) {
133
+ return { outcome: "unsupported", reason: "response_too_large" };
134
+ }
135
+ const body = parseJsonObject(response);
136
+ if (body === null || typeof body.deployment_profile !== "string") {
137
+ return { outcome: "unsupported", reason: "invalid_response" };
138
+ }
139
+ if (!SUPPORTED_PROFILES.includes(body.deployment_profile)) {
140
+ report.profile_status = "unrecognized";
141
+ return { outcome: "unsupported", reason: "unrecognized_profile" };
142
+ }
143
+ report.deployment_profile = body.deployment_profile;
144
+ report.profile_status = "supported";
145
+ return { outcome: null, reason: null };
146
+ }
147
+
148
+ function evaluateCapabilities(response, report) {
149
+ if (response.status === 401) {
150
+ if (!hasBearerChallenge(response.headers["www-authenticate"])) {
151
+ return { outcome: "unsupported", reason: "unexpected_auth_challenge" };
152
+ }
153
+ report.authentication_required = true;
154
+ report.next_action = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
155
+ // The check passed: the server behaved as expected. The outcome is still
156
+ // not a connection, because this CLI holds no credentials.
157
+ return { outcome: "authentication_required", reason: "bearer_token_required", pass: true };
158
+ }
159
+ if (response.status === 200) {
160
+ report.authentication_required = false;
161
+ return { outcome: "unsupported", reason: "unexpected_unauthenticated_access" };
162
+ }
163
+ return { outcome: "unsupported", reason: "unexpected_status" };
164
+ }
165
+
166
+ // RFC 9110 challenge grammar, applied to one comma-separated list element:
167
+ // auth-scheme [ 1*SP ( token68 / auth-param ) ] or a further auth-param.
168
+ // Only the first form names a scheme, so "Bearer=x" or "Bearer = x" is a
169
+ // parameter, never a challenge.
170
+ const TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
171
+ const QUOTED = '"(?:[^"\\\\]|\\\\.)*"';
172
+ const PARAM = `${TOKEN}[ \\t]*=[ \\t]*(?:${TOKEN}|${QUOTED})`;
173
+ const TOKEN68 = "[A-Za-z0-9._~+/-]+=*";
174
+ const ELEMENT = new RegExp(`^(?:(${TOKEN})(?:[ \\t]+(?:${TOKEN68}|${PARAM}))?|${PARAM})$`);
175
+
176
+ // True when the header holds a Bearer challenge. Commas inside quoted strings
177
+ // (with backslash escapes) do not split elements. Any malformed element,
178
+ // including unbalanced quoting, makes the whole header unacceptable.
179
+ function hasBearerChallenge(value) {
180
+ if (typeof value !== "string") return false;
181
+ const elements = [];
182
+ let start = 0;
183
+ let quoted = false;
184
+ for (let i = 0; i < value.length; i += 1) {
185
+ const char = value[i];
186
+ if (quoted) {
187
+ if (char === "\\") i += 1;
188
+ else if (char === '"') quoted = false;
189
+ } else if (char === '"') {
190
+ quoted = true;
191
+ } else if (char === ",") {
192
+ elements.push(value.slice(start, i));
193
+ start = i + 1;
194
+ }
195
+ }
196
+ if (quoted) return false;
197
+ elements.push(value.slice(start));
198
+
199
+ let bearer = false;
200
+ for (const element of elements) {
201
+ const trimmed = element.trim();
202
+ if (trimmed === "") continue;
203
+ const match = ELEMENT.exec(trimmed);
204
+ if (match === null) return false;
205
+ if (match[1] !== undefined && match[1].toLowerCase() === "bearer") bearer = true;
206
+ }
207
+ return bearer;
208
+ }