[object Object]

← back to Cli Printing Press

fix(cli): derive doctor auth.verify_path from me-shaped endpoint (#1431)

95f0350152285207c99616b3d80869471af9957e · 2026-05-14 23:59:47 -0700 · Trevin Chow

When a spec omits auth.verify_path, the generated doctor renders the
"present (not verified)" placeholder for credentials, which leaves
operators no way to tell a valid key from an invalid one without editing
the spec. PR #1406 introduced a me-shaped path derivation for the
unauthenticated reachability probe; the credentials probe is the
symmetric case and benefits from the same heuristic.

Mirror the HealthCheckPath derivation for Auth.VerifyPath at the top of
Generator.Generate():
  1. Auth.VerifyPath (explicit operator override, never clobbered)
  2. Heuristic me-shaped GET endpoint (users/me, viewer, account, ...)
  3. Empty -> template falls back to the existing "present (not verified)"
     branch so we never fabricate a verdict from a public path.

Run the new derivation before deriveHealthCheckPath so HealthCheckPath's
fallback chain still picks up the (now-populated) Auth.VerifyPath, which
keeps the two paths in lockstep when both fall through to the heuristic.

Closes #1161

Files touched

Diff

commit 95f0350152285207c99616b3d80869471af9957e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 23:59:47 2026 -0700

    fix(cli): derive doctor auth.verify_path from me-shaped endpoint (#1431)
    
    When a spec omits auth.verify_path, the generated doctor renders the
    "present (not verified)" placeholder for credentials, which leaves
    operators no way to tell a valid key from an invalid one without editing
    the spec. PR #1406 introduced a me-shaped path derivation for the
    unauthenticated reachability probe; the credentials probe is the
    symmetric case and benefits from the same heuristic.
    
    Mirror the HealthCheckPath derivation for Auth.VerifyPath at the top of
    Generator.Generate():
      1. Auth.VerifyPath (explicit operator override, never clobbered)
      2. Heuristic me-shaped GET endpoint (users/me, viewer, account, ...)
      3. Empty -> template falls back to the existing "present (not verified)"
         branch so we never fabricate a verdict from a public path.
    
    Run the new derivation before deriveHealthCheckPath so HealthCheckPath's
    fallback chain still picks up the (now-populated) Auth.VerifyPath, which
    keeps the two paths in lockstep when both fall through to the heuristic.
    
    Closes #1161
---
 internal/generator/doctor_health_path.go      |  37 +++++++
 internal/generator/doctor_health_path_test.go | 134 ++++++++++++++++++++++++++
 internal/generator/generator.go               |   9 ++
 3 files changed, 180 insertions(+)

diff --git a/internal/generator/doctor_health_path.go b/internal/generator/doctor_health_path.go
index ed04f774..aa1fbd82 100644
--- a/internal/generator/doctor_health_path.go
+++ b/internal/generator/doctor_health_path.go
@@ -51,6 +51,43 @@ func deriveHealthCheckPath(s *spec.APISpec) string {
 	if s.Auth.VerifyPath != "" {
 		return s.Auth.VerifyPath
 	}
+	return findMeShapedEndpointPath(s)
+}
+
+// deriveAuthVerifyPath chooses the path the generated `doctor` command should
+// hit for its authenticated credentials probe. Mirrors deriveHealthCheckPath
+// but for the auth-required side of the doctor report: a verify path that
+// returns 401 on bad credentials and 2xx on good ones lets doctor distinguish
+// "credentials accepted" from "credentials silently present but never tested."
+//
+// Priority:
+//  1. Auth.VerifyPath when set (explicit user override, never clobbered).
+//  2. A heuristic me-shaped GET endpoint discovered in the spec. Me-shaped
+//     endpoints (`/users/me`, `/account`, `/viewer`, etc.) describe the
+//     calling identity and almost universally require auth, which is exactly
+//     the contract the credentials probe needs.
+//  3. Empty string. The doctor template falls back to reporting credentials
+//     as "present (not verified — set auth.verify_path in spec for an API
+//     acceptance check)" rather than fabricating a verdict from `/`.
+//
+// Returning empty in case 3 keeps the template's existing "not verified"
+// branch authoritative; the goal is to skip that branch whenever the spec
+// gives us a defensible me-shaped path to probe, not to invent one.
+func deriveAuthVerifyPath(s *spec.APISpec) string {
+	if s == nil {
+		return ""
+	}
+	if s.Auth.VerifyPath != "" {
+		return s.Auth.VerifyPath
+	}
+	return findMeShapedEndpointPath(s)
+}
+
+// findMeShapedEndpointPath walks the spec for the first GET endpoint whose
+// path matches one of meShapedPathTails (most-specific tail first). Returns
+// "" when no candidate qualifies — used as the shared heuristic step inside
+// the two derivation helpers above.
+func findMeShapedEndpointPath(s *spec.APISpec) string {
 	for _, tail := range meShapedPathTails {
 		if e, ok := findEndpointMatch(s, func(e spec.Endpoint) bool {
 			return isMeShapedEndpoint(e, tail)
diff --git a/internal/generator/doctor_health_path_test.go b/internal/generator/doctor_health_path_test.go
index 2e652698..507211bb 100644
--- a/internal/generator/doctor_health_path_test.go
+++ b/internal/generator/doctor_health_path_test.go
@@ -327,6 +327,140 @@ func TestGeneratedDoctor_KeepsExplicitHealthCheckPath(t *testing.T) {
 	assert.Contains(t, string(doctorGo), `healthPath := "api/marketStatus"`)
 }
 
+func TestDeriveAuthVerifyPath_PrioritizesExplicitOverride(t *testing.T) {
+	t.Parallel()
+
+	s := minimalSpec("explicit-verify")
+	s.Auth.VerifyPath = "/operator-vetted"
+	// Add a me-shaped endpoint that would otherwise win.
+	s.Resources["users"] = spec.Resource{
+		Endpoints: map[string]spec.Endpoint{
+			"me": {Method: "GET", Path: "/users/me"},
+		},
+	}
+
+	assert.Equal(t, "/operator-vetted", deriveAuthVerifyPath(s),
+		"explicit Auth.VerifyPath must not be overwritten by the me-shaped heuristic")
+}
+
+func TestDeriveAuthVerifyPath_PicksMeShapedEndpoint(t *testing.T) {
+	t.Parallel()
+
+	s := minimalSpec("me-fallback")
+	s.Resources["users"] = spec.Resource{
+		Endpoints: map[string]spec.Endpoint{
+			"me": {Method: "GET", Path: "/users/me"},
+		},
+	}
+
+	assert.Equal(t, "/users/me", deriveAuthVerifyPath(s),
+		"a me-shaped GET should be discovered when Auth.VerifyPath is unset")
+}
+
+func TestDeriveAuthVerifyPath_FallsBackToEmpty(t *testing.T) {
+	t.Parallel()
+
+	// minimalSpec's only endpoint is /items — not me-shaped. The function
+	// must return "" so the doctor template keeps emitting the existing
+	// "present (not verified)" branch rather than fabricating a probe.
+	s := minimalSpec("no-candidate")
+	assert.Equal(t, "", deriveAuthVerifyPath(s),
+		"no me-shaped tail match must surface as empty so the template fallback wins")
+}
+
+func TestDeriveAuthVerifyPath_NilSpec(t *testing.T) {
+	t.Parallel()
+	assert.Equal(t, "", deriveAuthVerifyPath(nil))
+}
+
+// TestGeneratedDoctor_DerivesAuthVerifyPathFromMeEndpoint wires the helper
+// through Generate(): a spec with a me-shaped GET and no explicit
+// Auth.VerifyPath must emit a doctor.go that actually probes that endpoint
+// for credential validity, not the "present (not verified)" placeholder.
+func TestGeneratedDoctor_DerivesAuthVerifyPathFromMeEndpoint(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("derive-verify-me")
+	apiSpec.Resources["users"] = spec.Resource{
+		Description: "Users",
+		Endpoints: map[string]spec.Endpoint{
+			"me": {Method: "GET", Path: "/users/me", Description: "Get current user"},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "derive-verify-me-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `verifyPath := "/users/me"`,
+		"doctor should probe the derived me-shaped path for credential validity")
+	assert.Contains(t, content, `c.GetWithHeaders(verifyPath`,
+		"doctor should issue the authenticated probe through the configured client")
+	assert.NotContains(t, content, `"present (not verified — set auth.verify_path in spec for an API acceptance check)"`,
+		"the no-verify-path placeholder branch must not be rendered once a path is derived")
+	assert.Equal(t, "/users/me", apiSpec.Auth.VerifyPath,
+		"derived value should be visible on spec after Generate()")
+}
+
+// TestGeneratedDoctor_KeepsExplicitAuthVerifyPath guards the override path:
+// an operator-supplied auth.verify_path must survive Generate() even when the
+// spec also ships a me-shaped endpoint that the heuristic would otherwise pick.
+func TestGeneratedDoctor_KeepsExplicitAuthVerifyPath(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("explicit-verify-override")
+	apiSpec.Auth.VerifyPath = "/operator-vetted"
+	apiSpec.Resources["users"] = spec.Resource{
+		Description: "Users",
+		Endpoints: map[string]spec.Endpoint{
+			"me": {Method: "GET", Path: "/users/me", Description: "Get current user"},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "explicit-verify-override-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	assert.Contains(t, content, `verifyPath := "/operator-vetted"`)
+	assert.NotContains(t, content, `verifyPath := "/users/me"`)
+}
+
+// TestGenerate_AuthVerifyPathDerivationIsIdempotent pins re-entry behavior:
+// regen-merge and mcp-sync re-invoke Generate() on a spec that already saw
+// the derivation pass; the second call must observe the populated value and
+// emit byte-identical output.
+func TestGenerate_AuthVerifyPathDerivationIsIdempotent(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("verify-idempotent")
+	apiSpec.Resources["users"] = spec.Resource{
+		Endpoints: map[string]spec.Endpoint{
+			"me": {Method: "GET", Path: "/users/me"},
+		},
+	}
+
+	firstDir := filepath.Join(t.TempDir(), "first")
+	require.NoError(t, New(apiSpec, firstDir).Generate())
+	first, err := os.ReadFile(filepath.Join(firstDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+
+	secondDir := filepath.Join(t.TempDir(), "second")
+	require.NoError(t, New(apiSpec, secondDir).Generate())
+	second, err := os.ReadFile(filepath.Join(secondDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+
+	assert.Equal(t, string(first), string(second),
+		"second Generate() must emit byte-identical doctor.go")
+	assert.Equal(t, "/users/me", apiSpec.Auth.VerifyPath,
+		"derived value should persist on spec across Generate() calls")
+}
+
 // TestGeneratedDoctor_NoCandidateFallsBackToRoot keeps the negative case
 // stable: a spec with nothing me-shaped renders the `/`-probe branch the
 // pre-derivation template has always emitted.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 0450e8e8..c6e7c2c2 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1637,6 +1637,15 @@ func (g *Generator) Generate() error {
 				"Set `git config github.user` (your GitHub @handle) to populate this correctly before publishing.\n",
 		)
 	}
+	// Auth.VerifyPath drives the doctor's credentials probe. Derive it before
+	// HealthCheckPath so HealthCheckPath's fallback chain can pick up the
+	// derived value (mirroring how an operator-set auth.verify_path already
+	// flows through). Without this, specs that ship a me-shaped endpoint but
+	// no explicit auth.verify_path generate a doctor that reports credentials
+	// as merely "present (not verified)" instead of probing the API.
+	if g.Spec.Auth.VerifyPath == "" {
+		g.Spec.Auth.VerifyPath = deriveAuthVerifyPath(g.Spec)
+	}
 	if g.Spec.HealthCheckPath == "" {
 		g.Spec.HealthCheckPath = deriveHealthCheckPath(g.Spec)
 	}

← 2988814e fix(skills): canonicalize auth env var when slug-derived dif  ·  back to Cli Printing Press  ·  fix(cli): bypass response cache when polling async jobs (#14 acbb3dfe →