diff --git a/CLAUDE.md b/CLAUDE.md index 72a7185..48c4124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # Searchlight CLI — Project Contract -A thin Go client over the Searchlight MCP server (eva-web). The tool surface is -fully derived at runtime from the MCP server's `tools/list`, so adding new -tools server-side requires zero changes here. Audience is internal Headline -users + portfolio CEOs (today) and sophisticated external investors (after the -`is_internal?` gate is relaxed server-side). +A thin Go client over the Searchlight MCP server. The tool surface is fully +derived at runtime from the MCP server's `tools/list`, so adding new tools +server-side requires zero changes here. Audience is internal Headline users + +portfolio CEOs (today) and sophisticated external investors (after the +internal-user gate is relaxed server-side). ## What this CLI is for @@ -22,7 +22,7 @@ Linear ticket: [EVA-9938](https://linear.app/headline/issue/EVA-9938/create-a-se ``` ┌─────────────────────────┐ OAuth PKCE ┌──────────────────────┐ -│ searchlight CLI (Go) │ ◄─────────────────────────► │ eva-web /oauth/* │ +│ searchlight CLI (Go) │ ◄─────────────────────────► │ searchlight /oauth/*│ │ ───────────────────── │ │ /.well-known/* │ │ cobra root │ JSON-RPC POST │ │ │ ├─ auth login/logout │ ──────────────────────────► │ /mcp │ @@ -92,11 +92,10 @@ Every invocation: ### Auth flow -PKCE + browser loopback against eva-web's custom OAuth implementation. All the -relevant server endpoints already exist (`POST /oauth/token`, -`/.well-known/oauth-authorization-server`, `OauthApplication` with wildcard -redirect URI support, PKCE S256 enforced in -`app/services/mcp/oauth/authorization_service.rb`). +PKCE + browser loopback against the Searchlight server's custom OAuth +implementation. The server exposes the standard endpoints (`POST /oauth/token`, +`/.well-known/oauth-authorization-server`) and supports OAuth applications +with wildcard loopback redirect URIs and PKCE S256 challenges. The CLI: @@ -157,7 +156,7 @@ CGO_ENABLED=0 go build \ for internal/test code where exit code doesn't matter. - **Comments**: explain WHY in domain terms, not WHAT. Never reference Linear ticket numbers in source (they go in commit messages, PR titles, branch names - only — same rule as eva-web). + only). - **No `panic`** in CLI paths. Return errors. Tests can `t.Fatalf` freely. - **HTTP clients**: always set a timeout. The MCP client uses 60s, OAuth endpoints use 5min for the browser-loopback window. @@ -168,7 +167,7 @@ CGO_ENABLED=0 go build \ ## Testing Philosophy -Inherited from the eva-web project — applies here verbatim: +Inherited from our other internal projects — applies here verbatim: - **Never re-implement logic in tests.** Hardcode expected values from manually-verified examples. If the production code computes @@ -189,23 +188,17 @@ Inherited from the eva-web project — applies here verbatim: ## OAuth client setup (one-time, manual) The CLI is a public OAuth client (no client_secret; PKCE proves possession). -Two `OauthApplication` rows must exist in eva-web — one in production, one in -staging. Run this in the **eva-web** Rails console for each environment: - -```ruby -OauthApplication.create!( - name: "Searchlight CLI", - client_id: SecureRandom.uuid, # record this → GitHub secret - client_secret: nil, - redirect_uris: ["http://localhost:*", "http://127.0.0.1:*"], - grant_types: %w[authorization_code refresh_token], - response_types: %w[code], -) -``` +A Searchlight admin must register two OAuth applications on the server — one +in production, one in staging — each with: + +- name: `Searchlight CLI` +- client_id: a freshly minted UUID (record it — this becomes a GitHub secret) +- client_secret: empty (PKCE-only) +- redirect_uris: `http://localhost:*`, `http://127.0.0.1:*` (loopback only) +- grant_types: `authorization_code`, `refresh_token` +- response_types: `code` -The wildcard redirect URI support is already implemented in -`app/models/oauth_application.rb:13-21`. Record both `client_id`s and stash -them as GitHub secrets in this repo: +Record both `client_id`s and stash them as GitHub secrets in this repo: - `SEARCHLIGHT_PROD_CLIENT_ID` - `SEARCHLIGHT_STAGING_CLIENT_ID` @@ -281,8 +274,9 @@ Before the first release can succeed: 1. Create `headlinevc/homebrew-tap` repo (public, can be empty). 2. Create a PAT with `contents:write` on `homebrew-tap`, store as `HOMEBREW_TAP_TOKEN` secret on this repo. -3. Create the `OauthApplication` rows (see above) and stash - `SEARCHLIGHT_PROD_CLIENT_ID` + `SEARCHLIGHT_STAGING_CLIENT_ID` secrets. +3. Register the production + staging OAuth applications on the server (see + above) and stash `SEARCHLIGHT_PROD_CLIENT_ID` + `SEARCHLIGHT_STAGING_CLIENT_ID` + secrets. ## Safety Rails @@ -322,11 +316,11 @@ Before the first release can succeed: ## Known Constraints / Open follow-ups -1. **`is_internal?` gate.** Every MCP request hits - `lib/mcp/auth/authenticator.rb` which requires `user.is_internal? == true`. - The CLI's intended investor audience is currently blocked by this. Needs a - server-side change to scope access per `OauthApplication` type before - external rollout. Tracked as a follow-up on EVA-9938 (separate ticket). +1. **Internal-user gate.** The Searchlight server currently requires the + authenticated user to be a Headline internal user on every MCP request. + The CLI's intended external-investor audience is blocked by this until + the server scopes access by OAuth application type. Tracked as a + follow-up Linear issue. 2. **`go install` UX.** Lacks the ldflags-injected client_id; the user falls back to the `SEARCHLIGHT_CLIENT_ID` env var. README + docs/agents.md explain this. Acceptable for dev workflows, not for investor onboarding. @@ -381,8 +375,8 @@ Before declaring work complete, confirm: When context compaction triggers, preserve in priority order: -1. Architecture decisions (NEVER summarize away — `is_internal?` constraint, - dynamic registration design, conventional-commit requirement) +1. Architecture decisions (NEVER summarize away — internal-user gate + constraint, dynamic registration design, conventional-commit requirement) 2. Modified files and their key changes 3. Current test pass/fail + coverage status 4. Open follow-ups (1–5 in "Known Constraints" above) diff --git a/README.md b/README.md index 0e894ba..c1227d1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ auth, same payloads — but driven from the shell, optimized for agents and powe users. > v0 audience: internal Headline users + portfolio CEOs already in the -> `is_internal?` allowlist on the eva-web server. External investors are +> internal-user allowlist on the Searchlight server. External investors are > blocked until that gate is relaxed; tracked as a follow-up Linear issue. ## Install @@ -42,7 +42,7 @@ searchlight lookup_company --domain openai.com --pretty ## Authentication -The CLI uses OAuth 2.0 authorization_code + PKCE (S256) against the eva-web +The CLI uses OAuth 2.0 authorization_code + PKCE (S256) against the Searchlight MCP server, with a loopback redirect on an ephemeral port. Tokens are stored in your OS keyring (macOS Keychain / Windows Credential Manager / libsecret), falling back to a 0600 file at `$XDG_CONFIG_HOME/searchlight/credentials.json` @@ -115,9 +115,10 @@ CGO_ENABLED=0 go build \ SEARCHLIGHT_URL=https://staging.searchlight.io ./searchlight auth login ``` -The two `client_id` values are minted by a one-time `OauthApplication.create!` -in the eva-web Rails console (production and staging) — see `CLAUDE.md` for -the exact snippet and ask an admin for the values. +The two `client_id` values are minted once by a Searchlight admin when +registering the production and staging OAuth applications on the server — +see `CLAUDE.md` for the required application config, and ask an admin for +the values. ## Contributing & Releases @@ -153,7 +154,5 @@ owns both. ## Related -- Eva-web MCP tool source: `lib/mcp/tools/*.rb` (124 tools as of server v4.2.0) -- Eva-web MCP transport: `app/controllers/api/mcp_controller.rb` -- Linear ticket: EVA-9938 - Full project contract: `CLAUDE.md` +- Agent invariants: `docs/agents.md` diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 77d315b..c7eaca9 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -103,7 +103,7 @@ func newIntegrationFixture(t *testing.T) *integrationFixture { handler := &integrationHandler{ respond: map[string]func(map[string]any) (any, bool){ "get_current_user": func(_ map[string]any) (any, bool) { - return map[string]any{"email": "diogo@example.com", "is_internal": true}, false + return map[string]any{"email": "user@example.com", "is_internal": true}, false }, }, } @@ -212,8 +212,8 @@ func TestAuthWhoami_Integration(t *testing.T) { if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("output is not JSON: %q", out) } - if got["email"] != "diogo@example.com" { - t.Errorf("email = %v, want diogo@example.com", got["email"]) + if got["email"] != "user@example.com" { + t.Errorf("email = %v, want user@example.com", got["email"]) } } diff --git a/internal/config/config.go b/internal/config/config.go index 74417fc..eb2825e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,7 +16,7 @@ const ( ) // ClientIDs are baked at build time via -ldflags="-X ...prodClientID=..." after -// an admin creates the OauthApplication rows on staging and prod Rails consoles. +// an admin registers the staging and prod OAuth applications on the server. // See CLAUDE.md, "OAuth client setup (one-time, manual)". var ( prodClientID = "" diff --git a/internal/oauth/pkce.go b/internal/oauth/pkce.go index 39826b3..1b7a751 100644 --- a/internal/oauth/pkce.go +++ b/internal/oauth/pkce.go @@ -14,8 +14,8 @@ type PKCE struct { } // NewPKCE returns a 43-byte verifier (256 bits of entropy) and its S256 challenge. -// Length is intentional: the eva-web authorization_service.rb requires the S256 -// challenge to be exactly 43 base64url chars (i.e. 32 raw bytes), matching RFC 7636. +// Length is intentional: the server enforces an exact-length S256 challenge of +// 43 base64url chars (i.e. 32 raw bytes), matching RFC 7636. func NewPKCE() (*PKCE, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil {