diff --git a/README.md b/README.md index 0938501..04cbb3e 100644 --- a/README.md +++ b/README.md @@ -11,32 +11,28 @@ Monarch recently shut down their MCP solution. Other solutions are written in interpreted languages. I wanted a self-contained, single binary solution. And I like learning and building things. -## What it does +## Features - Lists accounts, transactions, categories, and budgets. - Fetches transaction details, cashflow summaries, and a financial overview. -- Provides responsive terminal tables and a keyboard-driven transaction pager. -- Emits machine-readable JSON for scripts and pipes. -- Serves the same read-only operations as MCP tools over stdio. -- Stores sessions in the operating system keyring—never a plaintext config file. +- Nice TUI for browsing your transactions. +- Structured output mode for all your agents <3 +- All features are available as MCP tools over stdio. +- Stores sessions in the operating system keychain for safety. -Write operations and arbitrary GraphQL are deliberately out of scope. +No write operations (yet). ## Install -### Tell an agent +Tell Codex or another agent to set this up: -Give your coding agent the following instructions: - -> Install Monarch CLI from `github.com/matteing/monarch-cli`. Inspect the -> repository before running anything, use mise to download the pinned Go -> toolchain, run the full audit, build and install the CLI, then configure its -> local stdio MCP server. Discover the available commands with `monarch --help` -> and the relevant subcommand help, inspect the MCP tools, and write your own -> local skill for using the read-only interface safely. Do not ask me for -> credentials or place them in commands—stop and ask me to run -> `monarch auth login` in a real terminal. Finish by running `monarch doctor` -> without printing my financial data. +> Install Monarch CLI from `github.com/matteing/monarch-cli`. Review the source, +> use its mise-pinned Go toolchain, run `mise run audit` and `mise run install`, +> then configure stdio MCP. Discover commands with `monarch --help` and inspect +> the MCP tools. Create a local skill named **Monarch CLI** with the exact +> description `Query Monarch Money transactions, accounts, balances, and more.` +> Keep it read-only. Never handle my credentials; ask me to run +> `monarch auth login`. Verify with `monarch doctor` without exposing my data. ### Install manually @@ -53,46 +49,18 @@ mise run install monarch --help ``` -The repository pins the supported Go toolchain in `mise.toml`; a separate Go -installation is not required. On Unix-like systems, `mise run install` writes -to `~/.local/bin` by default. On Windows it uses -`%LOCALAPPDATA%\monarch-cli\bin`. Set `MONARCH_INSTALL_DIR` to choose another -user-owned directory. The selected directory must be on `PATH`: add -`$HOME/.local/bin` to your shell profile on Unix-like systems, or add the -Windows directory to your user `PATH`, when it is not already present. - -Release builds report their tag through `monarch --version`. Source builds -report `dev-` and append `-dirty` for local changes (or `dev-dirty` -before a checkout has its first commit), so update verification is meaningful. - ## Sign in ```sh monarch auth login -monarch auth status -monarch doctor ``` Your password and MFA code are not stored anywhere. Monarch CLI keeps only the -resulting session credential in your OS keyring. Cancellation is honored until -the keyring commit begins. Native keyring writes cannot be canceled once they -start, so the CLI waits for that commit and reports its result. - -If password login is blocked by CAPTCHA, you can import an existing browser -session instead: +resulting session credential in your OS keyring. -```sh -monarch auth login --method browser-session -``` +### Multiple accounts -Copy the `Cookie` request header from an authenticated request in a signed-in -`app.monarch.com` tab. The prompt is hidden, and only the cookies required for -authentication are retained in the OS keyring. There is no plaintext fallback. -Saved records are strict and minimal; if an older record contains extra fields -or cookies, replace it explicitly with `monarch auth login --force`. - -Profiles are just named keyring slots. Ignore them if you use one Monarch -account. Names are 1–64 characters, start with a letter or digit, and otherwise +We support profiles, which are just different keyring slots. Names are 1–64 characters, start with a letter or digit, and otherwise use only letters, digits, dots, underscores, or hyphens. If you use more than one account or household: @@ -105,6 +73,7 @@ monarch --profile household auth logout ## Use the CLI ```text +# some sample commands... monarch accounts list monarch transactions list [filters] monarch transactions get TRANSACTION_ID @@ -114,17 +83,9 @@ monarch cashflow summary monarch overview ``` -Human-readable tables are the default. In a terminal, `transactions list` -opens a responsive pager and groups transactions by month: - -- `up` / `down` scroll the current page. -- `left` / `right` load the previous or next API page. -- `q`, `esc`, or `ctrl+c` close the pager. +Human-readable tables are the default. -Visited pages are cached. Use `--group none` for one continuous table, -`--limit` to set a page size, or `--cursor` to continue from an earlier result. - -For automation, use JSON: +For agents, use JSON: ```sh monarch transactions list \ @@ -134,15 +95,19 @@ monarch transactions list \ --output json ``` -JSON and piped table output return one bounded page. The JSON response includes -an opaque `next_cursor` when another page is available. Reuse a cursor only with -the same normalized filters and ordering that produced it. Amounts are exact -decimal strings rather than JSON numbers. +- JSON and piped table output return one bounded page. +- The JSON response includes + an opaque `next_cursor` when another page is available. +- Reuse a cursor only with + the same normalized filters and ordering that produced it. +- Amounts are exact + decimal strings rather than JSON numbers. ## Use the MCP server -Sign in once from a real terminal, then configure your MCP host with the -absolute path to the binary: +**You must login using a terminal app before setting this up**. + +After signing in, in your `mcp.json`: ```json { @@ -155,27 +120,11 @@ absolute path to the binary: } ``` -The stdio server exposes: - -```text -monarch_accounts_list -monarch_transactions_list -monarch_transaction_get -monarch_categories_list -monarch_budgets_get -monarch_cashflow_summary -monarch_financial_overview -``` - -Each tool has explicit input/output JSON Schema and read-only annotations. MCP -stdout is reserved for JSON-RPC; diagnostics go to stderr. Stdio input is -bounded to 512 KiB per newline-delimited message and 16 MiB per server process; -clients with unusually long sessions should reconnect before the session -budget is exhausted. +Afterwards, ask it what it can do! We expose _just about everything_ that the CLI can do. ## Configuration -Optional non-secret configuration lives at +Some non-secret config lives at `$XDG_CONFIG_HOME/monarch-cli/config.json` on Unix-like systems, or the platform-equivalent user config directory: @@ -193,6 +142,8 @@ Environment overrides are `MONARCH_PROFILE`, `MONARCH_OUTPUT`, `MONARCH_TIMEOUT`, `MONARCH_LOG_LEVEL`, and `MONARCH_LOG_FORMAT`. Flags take final precedence. None of these settings accepts credentials. +### Contracts + Stable exit codes are `2` for invalid input or a missing resource, `3` for authentication, `4` for a rate limit, `5` for upstream unavailability, and `6` for a keyring failure. Canceled commands use `130`; unexpected errors use `1`. @@ -207,7 +158,7 @@ mise run # list tasks mise run fmt # format source mise run check # format, module, test, vet, staticcheck, and build gate mise run audit # full gate: check, race, shuffle, coverage, vulnerabilities -mise run test:coverage # enforce the 65% statement coverage floor +mise run test:coverage # enforce the statement coverage floor mise run test:race # race-enabled tests mise run vuln # scan reachable code with pinned govulncheck mise run release:check # validate release configuration @@ -221,8 +172,7 @@ without printing your financial data: mise run test:live ``` -It requires a valid session in the selected profile. The normal test suite is -offline: it does not touch a real Monarch account or keyring. +It requires a valid session in the selected profile. Tagged releases are built for macOS, Linux, and Windows on `amd64` and `arm64`, with checksums. Push a tag such as `v0.1.0` to start the release workflow. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1544f5a..010c7cb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -111,10 +111,10 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string) return session.Session{}, apperr.New(apperr.KindUnavailable, op, "Monarch returned malformed login JSON", decodeErr) } if strings.Count(decoded.Token, ".") == 2 { - return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned a short-lived feature token; use browser-session login", nil) + return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned an unsupported short-lived feature token", nil) } if expiration := strings.TrimSpace(string(decoded.TokenExpiration)); expiration != "" && expiration != "null" && expiration != `"null"` { - return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned a short-lived token; use browser-session login", nil) + return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned an unsupported short-lived token", nil) } value, err := session.NewToken(decoded.Token) if err != nil { @@ -130,7 +130,7 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string) } if resp.StatusCode == http.StatusForbidden { if decodeErr == nil && strings.EqualFold(decoded.ErrorCode, "CAPTCHA_REQUIRED") { - return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "programmatic login is blocked by CAPTCHA; retry with `monarch auth login --method browser-session`", StatusCode: resp.StatusCode} + return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "programmatic login is blocked by CAPTCHA", StatusCode: resp.StatusCode} } if decodeErr == nil && mfaRequired(decoded.ErrorCode, decoded.Detail) { message := "MFA code required" @@ -148,7 +148,7 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string) } } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "Monarch rejected the login; check credentials or use browser-session login", StatusCode: resp.StatusCode} + return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "Monarch rejected the login; check your credentials", StatusCode: resp.StatusCode} } return session.Session{}, &apperr.Error{Kind: apperr.KindUnavailable, Op: op, Message: fmt.Sprintf("Monarch login failed with HTTP %d", resp.StatusCode), StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500} } diff --git a/internal/auth/method.go b/internal/auth/method.go deleted file mode 100644 index 2281497..0000000 --- a/internal/auth/method.go +++ /dev/null @@ -1,14 +0,0 @@ -package auth - -// Method identifies one supported interactive authentication flow. -type Method string - -const ( - MethodPassword Method = "password" - MethodBrowserSession Method = "browser-session" -) - -// Valid reports whether method names a supported authentication flow. -func (method Method) Valid() bool { - return method == MethodPassword || method == MethodBrowserSession -} diff --git a/internal/command/auth_login.go b/internal/command/auth_login.go index 3f73ce1..66c2f56 100644 --- a/internal/command/auth_login.go +++ b/internal/command/auth_login.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/matteing/monarch-cli/internal/apperr" - "github.com/matteing/monarch-cli/internal/auth" "github.com/matteing/monarch-cli/internal/session" "github.com/matteing/monarch-cli/internal/tui" ) @@ -22,7 +21,6 @@ type loginResult struct { } func (a *application) loginCommand() *cobra.Command { - var method string var force bool command := &cobra.Command{ Use: "login", @@ -31,10 +29,6 @@ func (a *application) loginCommand() *cobra.Command { "Your password and MFA code are not stored anywhere. They are discarded after login; password login keeps only the session token.", Args: noArgs, RunE: func(cmd *cobra.Command, _ []string) error { - loginMethod := auth.Method(method) - if !loginMethod.Valid() { - return apperr.New(apperr.KindInvalidInput, "login", "pick password or browser-session for --method", nil) - } if !force { existing, loadErr := a.store.Load(a.config.Profile) if loadErr == nil { @@ -52,7 +46,7 @@ func (a *application) loginCommand() *cobra.Command { } } - value, err := a.login(cmd.Context(), loginMethod) + value, err := a.login(cmd.Context()) if err != nil { return err } @@ -62,7 +56,7 @@ func (a *application) loginCommand() *cobra.Command { PasswordSaved: false, MFACodeSaved: false, }) } - message := loginSuccessMessage(value.Mode) + message := loginSuccessMessage() if tui.IsTerminal(a.out) { return tui.WriteSuccess(a.out, message) } @@ -70,19 +64,15 @@ func (a *application) loginCommand() *cobra.Command { return err }, } - command.Flags().StringVar(&method, "method", string(auth.MethodPassword), "login method: password or browser-session") command.Flags().BoolVar(&force, "force", false, "replace any existing saved session") return command } -func loginSuccessMessage(mode session.Mode) string { - if mode == session.ModeCookie { - return "Signed in. Only the required authentication cookies were retained in the OS keyring." - } +func loginSuccessMessage() string { return "Signed in. Only the session token was saved to the OS keyring." } -func (a *application) login(ctx context.Context, method auth.Method) (session.Session, error) { +func (a *application) login(ctx context.Context) (session.Session, error) { if !tui.IsTerminal(a.in) || !tui.IsTerminal(a.errOut) { return session.Session{}, apperr.New(apperr.KindInvalidInput, "login", "interactive login requires a terminal", nil) } @@ -90,9 +80,8 @@ func (a *application) login(ctx context.Context, method auth.Method) (session.Se return a.authenticate(ctx, a.config.Timeout, email, password, code) } return tui.RunLogin(tui.LoginOptions{ - Context: ctx, Input: a.in, Output: a.errOut, Method: method, Profile: a.config.Profile, + Context: ctx, Input: a.in, Output: a.errOut, Profile: a.config.Profile, Authenticate: authenticate, - ParseCookies: session.ParseCookieHeader, Verify: a.verify, Save: a.store.Save, }) diff --git a/internal/command/copy_test.go b/internal/command/copy_test.go index 4455415..d9c7b4c 100644 --- a/internal/command/copy_test.go +++ b/internal/command/copy_test.go @@ -3,12 +3,13 @@ package command import ( "strings" "testing" - - "github.com/matteing/monarch-cli/internal/session" ) func TestLoginCopyStaysFriendlyAndClear(t *testing.T) { command := (&application{}).loginCommand() + if command.Flags().Lookup("method") != nil { + t.Fatal("login still exposes authentication method selection") + } for _, phrase := range []string{"save a session credential", "not stored anywhere", "only the session token"} { if !strings.Contains(command.Short+"\n"+command.Long, phrase) { t.Fatalf("login copy does not contain %q", phrase) @@ -16,9 +17,9 @@ func TestLoginCopyStaysFriendlyAndClear(t *testing.T) { } } -func TestCookieLoginSuccessDescribesCredentialMinimization(t *testing.T) { - message := loginSuccessMessage(session.ModeCookie) - if !strings.Contains(message, "Only the required authentication cookies were retained") { - t.Fatalf("cookie login success = %q", message) +func TestLoginSuccessDescribesCredentialMinimization(t *testing.T) { + message := loginSuccessMessage() + if !strings.Contains(message, "Only the session token was saved") { + t.Fatalf("login success = %q", message) } } diff --git a/internal/command/root.go b/internal/command/root.go index 740b913..7748cac 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -83,7 +83,7 @@ func buildRoot(cfg config.Config, deps Dependencies) (*cobra.Command, *applicati } root := &cobra.Command{ Use: "monarch", Short: "Read-only Monarch Money CLI and MCP server", - Long: "monarch provides interactive login, read-only queries, JSON output, and a local stdio MCP server for Monarch Money.", + Long: "A joyful CLI utility and MCP server for Monarch Money.", Version: app.version, SilenceErrors: true, SilenceUsage: true, TraverseChildren: true, Args: subcommandsOnly, RunE: showHelp, Annotations: map[string]string{"help-only": "true"}, diff --git a/internal/monarch/client.go b/internal/monarch/client.go index 1cd46d8..2a01f96 100644 --- a/internal/monarch/client.go +++ b/internal/monarch/client.go @@ -5,8 +5,6 @@ import ( "errors" "net/http" "net/url" - "sort" - "strings" "time" "github.com/matteing/monarch-cli/internal/apperr" @@ -41,9 +39,6 @@ type Client struct { httpClient *http.Client endpoint string authorization string - cookieHeader string - csrfToken string - cookieMode bool requests chan struct{} now func() time.Time retryWait retryWaitFunc @@ -89,23 +84,6 @@ func newClient(value session.Session, httpClient *http.Client, endpoint string) now: time.Now, retryWait: waitForRetry, } - switch value.Mode { - case session.ModeToken: - client.authorization = "Token " + value.Token() - case session.ModeCookie: - cookies := value.Cookies() - names := make([]string, 0, len(cookies)) - for name := range cookies { - names = append(names, name) - } - sort.Strings(names) - pairs := make([]string, 0, len(names)) - for _, name := range names { - pairs = append(pairs, name+"="+cookies[name]) - } - client.cookieHeader = strings.Join(pairs, "; ") - client.csrfToken = value.Cookie("csrftoken") - client.cookieMode = true - } + client.authorization = "Token " + value.Token() return client, nil } diff --git a/internal/monarch/client_test.go b/internal/monarch/client_test.go index e29efed..f3bb1bf 100644 --- a/internal/monarch/client_test.go +++ b/internal/monarch/client_test.go @@ -40,30 +40,6 @@ func TestListAccountsUsesTokenAndFiltersHidden(t *testing.T) { } } -func TestCookieSessionHeaders(t *testing.T) { - transport := monarchRoundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.Header.Get("X-Csrftoken") != "csrf" { - t.Errorf("missing CSRF header") - } - if r.Header.Get("Origin") != "https://app.monarch.com" { - t.Errorf("missing Origin header") - } - cookie := r.Header.Get("Cookie") - if !strings.Contains(cookie, "session_id=sid") || !strings.Contains(cookie, "csrftoken=csrf") { - t.Errorf("Cookie = %q", cookie) - } - return monarchResponse(http.StatusOK, `{"data":{"categories":[]}}`), nil - }) - client, err := newClient(mustCookieSession(t, map[string]string{"session_id": "sid", "csrftoken": "csrf"}), &http.Client{Transport: transport}, "https://example.test/graphql") - if err != nil { - t.Fatal(err) - } - client.retryWait = noRetryWait - if _, err := client.ListCategories(context.Background()); err != nil { - t.Fatal(err) - } -} - func noRetryWait(context.Context, int, time.Duration) error { return nil } func TestTransientResponsesAreRetried(t *testing.T) { @@ -266,12 +242,3 @@ func mustTokenSession(t *testing.T, token string) session.Session { } return value } - -func mustCookieSession(t *testing.T, cookies map[string]string) session.Session { - t.Helper() - value, err := session.NewCookie(cookies) - if err != nil { - t.Fatal(err) - } - return value -} diff --git a/internal/monarch/hardening_test.go b/internal/monarch/hardening_test.go index 7ebd1b1..6072eb6 100644 --- a/internal/monarch/hardening_test.go +++ b/internal/monarch/hardening_test.go @@ -6,7 +6,6 @@ import ( "errors" "net/http" "strings" - "sync" "sync/atomic" "testing" "time" @@ -420,35 +419,6 @@ func TestTransactionDetailProjectionMatchesModel(t *testing.T) { } } -func TestClientSnapshotsCookieAuthentication(t *testing.T) { - input := map[string]string{"session_id": "sid", "csrftoken": "csrf"} - value := mustCookieSession(t, input) - client := newTestClientWithSession(t, value, monarchRoundTripFunc(func(request *http.Request) (*http.Response, error) { - if got := request.Header.Get("Cookie"); !strings.Contains(got, "session_id=sid") || !strings.Contains(got, "csrftoken=csrf") { - t.Errorf("Cookie = %q", got) - } - if got := request.Header.Get("X-Csrftoken"); got != "csrf" { - t.Errorf("X-Csrftoken = %q", got) - } - return monarchResponse(http.StatusOK, `{"data":{"categories":[]}}`), nil - })) - input["session_id"] = "mutated" - copy := value.Cookies() - copy["csrftoken"] = "mutated" - - var group sync.WaitGroup - for range 32 { - group.Add(1) - go func() { - defer group.Done() - if _, err := client.ListCategories(context.Background()); err != nil { - t.Error(err) - } - }() - } - group.Wait() -} - func TestNewClientRejectsRedirectsAndInvalidTimeout(t *testing.T) { if _, err := NewClient(mustTokenSession(t, "token"), 0); apperr.KindOf(err) != apperr.KindInvalidInput { t.Fatalf("zero timeout error = %v (%s)", err, apperr.KindOf(err)) diff --git a/internal/monarch/transport.go b/internal/monarch/transport.go index f9cdc72..3b4c199 100644 --- a/internal/monarch/transport.go +++ b/internal/monarch/transport.go @@ -11,8 +11,7 @@ import ( ) const ( - maxResponseSize = 10 << 20 - webClientVersion = "2025.05" + maxResponseSize = 10 << 20 ) var errResponseTooLarge = errors.New("response exceeds 10 MiB limit") @@ -29,14 +28,6 @@ func (c *Client) do(ctx context.Context, payload []byte) ([]byte, int, http.Head if c.authorization != "" { req.Header.Set("Authorization", c.authorization) } - if c.cookieMode { - req.Header.Set("Cookie", c.cookieHeader) - req.Header.Set("Origin", "https://app.monarch.com") - req.Header.Set("Referer", "https://app.monarch.com/") - req.Header.Set("X-Csrftoken", c.csrfToken) - req.Header.Set("monarch-client", "web") - req.Header.Set("monarch-client-version", webClientVersion) - } resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, nil, err diff --git a/internal/session/cookies.go b/internal/session/cookies.go deleted file mode 100644 index 1a3e135..0000000 --- a/internal/session/cookies.go +++ /dev/null @@ -1,89 +0,0 @@ -package session - -import ( - "errors" - "regexp" - "strings" - - "github.com/matteing/monarch-cli/internal/apperr" -) - -const ( - // MaxCookieHeaderBytes is the shared input bound for browser-cookie import. - MaxCookieHeaderBytes = 64 * 1024 - maxCookieValueBytes = maxSerializedSessionBytes -) - -var cookieNamePattern = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") - -var requiredCookieNames = [...]string{"session_id", "csrftoken"} - -// ParseCookieHeader converts a copied browser Cookie header into a validated session. -func ParseCookieHeader(header string) (Session, error) { - const op = "parse browser session" - if len(header) > MaxCookieHeaderBytes { - return Session{}, apperr.New(apperr.KindInvalidInput, op, "cookie header is unexpectedly large", nil) - } - if containsControl(header) { - return Session{}, apperr.New(apperr.KindInvalidInput, op, "cookie header contains invalid text", nil) - } - cookies := make(map[string]string, len(requiredCookieNames)) - for _, pair := range strings.Split(header, ";") { - name, value, ok := strings.Cut(strings.TrimSpace(pair), "=") - if !ok || name == "" || value == "" { - continue - } - if !requiredCookieName(name) { - continue - } - if !validCookie(name, value) { - return Session{}, apperr.New(apperr.KindInvalidInput, op, "cookie header contains an invalid cookie", nil) - } - cookies[name] = value - } - value, err := NewCookie(cookies) - if err != nil { - if errors.Is(err, errSessionTooLarge) { - return Session{}, apperr.New(apperr.KindInvalidInput, op, "browser cookies exceed the credential-vault size limit", err) - } - return Session{}, apperr.New(apperr.KindInvalidInput, op, "browser cookies must include session_id and csrftoken", err) - } - return value, nil -} - -func validCookie(name, value string) bool { - return cookieNamePattern.MatchString(name) && value != "" && len(value) <= maxCookieValueBytes && !strings.Contains(value, ";") && !containsControl(value) -} - -func requiredCookieName(name string) bool { - for _, required := range requiredCookieNames { - if name == required { - return true - } - } - return false -} - -func selectRequiredCookies(cookies map[string]string) map[string]string { - if len(cookies) == 0 { - return nil - } - selected := make(map[string]string, len(requiredCookieNames)) - for _, name := range requiredCookieNames { - if value, ok := cookies[name]; ok { - selected[name] = value - } - } - return selected -} - -func cloneCookies(cookies map[string]string) map[string]string { - if len(cookies) == 0 { - return nil - } - cloned := make(map[string]string, len(cookies)) - for name, value := range cookies { - cloned[name] = value - } - return cloned -} diff --git a/internal/session/record.go b/internal/session/record.go index 76d460b..c04a73b 100644 --- a/internal/session/record.go +++ b/internal/session/record.go @@ -34,8 +34,7 @@ var errSessionTooLarge = errors.New("session exceeds the credential-vault size l type Mode string const ( - ModeToken Mode = "token" // ModeToken authenticates with one opaque API token. - ModeCookie Mode = "cookie" // ModeCookie authenticates with the two required browser cookies. + ModeToken Mode = "token" // ModeToken authenticates with one opaque API token. ) // Session is the versioned credential record stored in the system keyring. @@ -45,16 +44,14 @@ type Session struct { Mode Mode `json:"mode"` CreatedAt time.Time `json:"created_at"` - token string - cookies map[string]string + token string } type sessionJSON struct { - Version int `json:"version"` - Mode Mode `json:"mode"` - Token string `json:"token,omitempty"` - Cookies map[string]string `json:"cookies,omitempty"` - CreatedAt time.Time `json:"created_at"` + Version int `json:"version"` + Mode Mode `json:"mode"` + Token string `json:"token,omitempty"` + CreatedAt time.Time `json:"created_at"` } // NewToken constructs a validated token-backed session. @@ -71,36 +68,14 @@ func NewToken(token string) (Session, error) { return value, nil } -// NewCookie constructs a validated browser-cookie-backed session. Only the -// cookies required for Monarch authentication are retained. -func NewCookie(cookies map[string]string) (Session, error) { - value := Session{ - Version: sessionVersion, - Mode: ModeCookie, - cookies: selectRequiredCookies(cookies), - CreatedAt: time.Now().UTC(), - } - if err := value.Validate(); err != nil { - return Session{}, err - } - return value, nil -} - -// Token returns the token credential, or an empty string for cookie sessions. +// Token returns the token credential. func (s Session) Token() string { return s.token } -// Cookies returns a defensive copy of the browser credentials. -func (s Session) Cookies() map[string]string { return cloneCookies(s.cookies) } - -// Cookie returns one browser credential without exposing mutable session state. -func (s Session) Cookie(name string) string { return s.cookies[name] } - // MarshalJSON preserves the version-one keyring format while credential fields // remain private and immutable to callers. func (s Session) MarshalJSON() ([]byte, error) { raw, err := json.Marshal(sessionJSON{ - Version: s.Version, Mode: s.Mode, Token: s.token, - Cookies: cloneCookies(s.cookies), CreatedAt: s.CreatedAt, + Version: s.Version, Mode: s.Mode, Token: s.token, CreatedAt: s.CreatedAt, }) if err != nil { return nil, err @@ -112,7 +87,7 @@ func (s Session) MarshalJSON() ([]byte, error) { } // UnmarshalJSON reads the version-one keyring format. Stored records must be -// canonical and minimal; extra cookies and unknown fields are rejected. +// canonical and minimal; unknown fields are rejected. func (s *Session) UnmarshalJSON(raw []byte) error { if len(raw) > maxSerializedSessionBytes { return fmt.Errorf("%w of %d bytes", errSessionTooLarge, maxSerializedSessionBytes) @@ -129,9 +104,6 @@ func (s *Session) UnmarshalJSON(raw []byte) error { } return err } - if hasUnneededCookies(decoded.Cookies) { - return errors.New("session record contains browser cookies that are not required for authentication") - } canonical, err := json.Marshal(decoded) if err != nil { return err @@ -141,20 +113,11 @@ func (s *Session) UnmarshalJSON(raw []byte) error { } *s = Session{ Version: decoded.Version, Mode: decoded.Mode, CreatedAt: decoded.CreatedAt, - token: decoded.Token, cookies: selectRequiredCookies(decoded.Cookies), + token: decoded.Token, } return nil } -func hasUnneededCookies(cookies map[string]string) bool { - for name := range cookies { - if !requiredCookieName(name) { - return true - } - } - return false -} - // Validate rejects malformed or incomplete credential records. func (s Session) Validate() error { if s.Version != sessionVersion { @@ -171,22 +134,6 @@ func (s Session) Validate() error { if s.token != strings.TrimSpace(s.token) || len(s.token) > maxTokenBytes || containsControl(s.token) { return errors.New("token session contains an invalid token") } - if len(s.cookies) != 0 { - return errors.New("token session cannot contain browser cookies") - } - case ModeCookie: - if s.token != "" { - return errors.New("cookie session cannot contain a token") - } - if len(s.cookies) != len(requiredCookieNames) { - return errors.New("cookie session requires only session_id and csrftoken") - } - for _, name := range requiredCookieNames { - value := s.cookies[name] - if !validCookie(name, value) { - return errors.New("cookie session contains an invalid cookie") - } - } default: return fmt.Errorf("unsupported session mode %q", s.Mode) } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 344481f..facc225 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -12,84 +12,23 @@ import ( "github.com/matteing/monarch-cli/internal/apperr" ) -func TestParseCookieHeaderKeepsOnlyRequiredCookies(t *testing.T) { - value, err := ParseCookieHeader("session_id=abc=123; csrftoken=def; harmless=value=with=equals") - if err != nil { - t.Fatal(err) - } - if value.Mode != ModeCookie { - t.Fatalf("mode = %q, want %q", value.Mode, ModeCookie) - } - if value.Cookie("session_id") != "abc=123" || value.Cookie("csrftoken") != "def" { - t.Fatalf("required cookies were changed: %#v", value.Cookies()) - } - if _, ok := value.Cookies()["harmless"]; ok { - t.Fatalf("non-authentication cookie was retained: %#v", value.Cookies()) - } -} - -func TestParseCookieHeaderRequiresMonarchCookies(t *testing.T) { - if _, err := ParseCookieHeader("foo=bar"); err == nil { - t.Fatal("ParseCookieHeader succeeded without required cookies") - } -} - -func TestParseCookieHeaderExplainsCredentialVaultSizeLimit(t *testing.T) { - header := "session_id=" + strings.Repeat("s", maxSerializedSessionBytes) + "; csrftoken=c" - _, err := ParseCookieHeader(header) - if apperr.KindOf(err) != apperr.KindInvalidInput || !strings.Contains(err.Error(), "credential-vault size") { - t.Fatalf("oversized cookie error = %v", err) - } -} - -func TestSessionConstructorsEstablishImmutableInvariants(t *testing.T) { +func TestTokenConstructorEnforcesCredentialInvariants(t *testing.T) { if _, err := NewToken(" token "); err == nil { t.Fatal("NewToken accepted surrounding whitespace") } if _, err := NewToken("token\nInjected: yes"); err == nil { t.Fatal("NewToken accepted a control character") } - - input := map[string]string{"session_id": "sid", "csrftoken": "csrf", "other": "discard"} - value, err := NewCookie(input) - if err != nil { - t.Fatal(err) - } - input["session_id"] = "mutated" - copy := value.Cookies() - copy["session_id"] = "also-mutated" - if value.Cookie("session_id") != "sid" { - t.Fatal("cookie session retained caller-owned mutable state") - } - if len(value.Cookies()) != 2 { - t.Fatalf("cookies = %#v, want only required credentials", value.Cookies()) - } - if _, err := NewCookie(map[string]string{"session_id": "sid"}); err == nil { - t.Fatal("NewCookie accepted incomplete credentials") - } -} - -func TestSessionRejectsMixedCredentialModes(t *testing.T) { - token := mustToken(t, "token") - token.cookies = map[string]string{"session_id": "sid", "csrftoken": "csrf"} - if err := token.Validate(); err == nil { - t.Fatal("token session accepted browser cookies") - } - - cookie := mustCookie(t, map[string]string{"session_id": "sid", "csrftoken": "csrf"}) - cookie.token = "token" - if err := cookie.Validate(); err == nil { - t.Fatal("cookie session accepted a token") - } } func TestKeyringStoreLoadRejectsNonMinimalRecordsWithoutWriting(t *testing.T) { records := map[string]string{ - "extra cookie": `{"version":1,"mode":"cookie","cookies":{"analytics":"nope","csrftoken":"csrf","session_id":"sid"},"created_at":"2026-07-25T12:00:00Z"}`, - "unknown field": `{"version":1,"mode":"token","token":"token","created_at":"2026-07-25T12:00:00Z","password":"must-not-be-ignored"}`, - "duplicate field": `{"version":1,"mode":"token","token":"old","token":"new","created_at":"2026-07-25T12:00:00Z"}`, - "field alias": `{"version":1,"mode":"token","Token":"token","created_at":"2026-07-25T12:00:00Z"}`, - "redundant null": `{"version":1,"mode":"token","token":"token","cookies":null,"created_at":"2026-07-25T12:00:00Z"}`, + "retired cookie mode": `{"version":1,"mode":"cookie","created_at":"2026-07-25T12:00:00Z"}`, + "retired cookie fields": `{"version":1,"mode":"cookie","cookies":{"csrftoken":"csrf","session_id":"sid"},"created_at":"2026-07-25T12:00:00Z"}`, + "unknown field": `{"version":1,"mode":"token","token":"token","created_at":"2026-07-25T12:00:00Z","password":"must-not-be-ignored"}`, + "duplicate field": `{"version":1,"mode":"token","token":"old","token":"new","created_at":"2026-07-25T12:00:00Z"}`, + "field alias": `{"version":1,"mode":"token","Token":"token","created_at":"2026-07-25T12:00:00Z"}`, + "redundant null": `{"version":1,"mode":"token","token":"token","cookies":null,"created_at":"2026-07-25T12:00:00Z"}`, } for name, record := range records { t.Run(name, func(t *testing.T) { @@ -129,19 +68,19 @@ func TestValidateProfile(t *testing.T) { func TestKeyringStoreRoundTripUsesInjectedBackend(t *testing.T) { backend := &fakeKeyring{} store := KeyringStore{backend: backend, readBlocked: func(_, _ string) bool { return false }} - value := mustCookie(t, map[string]string{"session_id": "sid", "csrftoken": "csrf", "other": "discard"}) + value := mustToken(t, "token") if err := store.Save("default", value); err != nil { t.Fatal(err) } - if backend.account != "session:default" || strings.Contains(backend.value, "other") { + if backend.account != "session:default" { t.Fatalf("unexpected persisted keyring record: account=%q value=%s", backend.account, backend.value) } loaded, err := store.Load("default") if err != nil { t.Fatal(err) } - if loaded.Cookie("session_id") != "sid" || loaded.Cookie("csrftoken") != "csrf" { - t.Fatalf("loaded session = %#v", loaded.Cookies()) + if loaded.Mode != ModeToken || loaded.Token() != "token" { + t.Fatalf("loaded session = mode %q token %q", loaded.Mode, loaded.Token()) } if err := store.Delete("default"); err != nil { t.Fatal(err) @@ -234,31 +173,6 @@ func TestKeyringAccessFailureClassification(t *testing.T) { } } -func FuzzParseCookieHeader(f *testing.F) { - for _, seed := range []string{ - "session_id=sid; csrftoken=csrf", - "session_id=a=b; csrftoken=c; ignored=value", - "session_id=bad\r\nvalue; csrftoken=csrf", - "", - } { - f.Add(seed) - } - f.Fuzz(func(t *testing.T, header string) { - value, err := ParseCookieHeader(header) - if err != nil { - return - } - if err := value.Validate(); err != nil { - t.Fatalf("successful parse returned invalid session: %v", err) - } - for name := range value.Cookies() { - if !requiredCookieName(name) { - t.Fatalf("unexpected cookie %q survived parsing", name) - } - } - }) -} - type fakeKeyring struct { value string account string @@ -291,12 +205,3 @@ func mustToken(t *testing.T, token string) Session { } return value } - -func mustCookie(t *testing.T, cookies map[string]string) Session { - t.Helper() - value, err := NewCookie(cookies) - if err != nil { - t.Fatal(err) - } - return value -} diff --git a/internal/tui/login.go b/internal/tui/login.go index 5d19706..58e54ac 100644 --- a/internal/tui/login.go +++ b/internal/tui/login.go @@ -8,7 +8,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/matteing/monarch-cli/internal/auth" "github.com/matteing/monarch-cli/internal/session" ) @@ -21,9 +20,6 @@ var ErrCanceled = fmt.Errorf("login canceled: %w", context.Canceled) // direct: the login secret is used for the request but never persisted. const PasswordPrivacyNotice = "Your password and MFA code aren't stored anywhere. They're discarded after login; we only keep the session token." -// BrowserPrivacyNotice explains the distinct storage behavior of cookie import. -const BrowserPrivacyNotice = "No email or password is used. Only the required session_id and csrftoken cookies are stored." - // LoginOptions supplies the required dependencies for the interactive login // form. Network callbacks must honor Context. Save begins only after successful // verification; once that credential-vault commit starts, UI cancellation is @@ -32,10 +28,8 @@ type LoginOptions struct { Context context.Context Input io.Reader Output io.Writer - Method auth.Method Profile string Authenticate func(context.Context, string, string, string) (session.Session, error) - ParseCookies func(string) (session.Session, error) Verify func(context.Context, session.Session) error Save func(string, session.Session) error } @@ -93,20 +87,11 @@ func validateLoginOptions(opts LoginOptions) error { if err := session.ValidateProfile(opts.Profile); err != nil { return fmt.Errorf("invalid login profile: %w", err) } + if opts.Authenticate == nil { + return errors.New("login authenticate callback is required") + } if opts.Verify == nil || opts.Save == nil { return errors.New("login verify and save callbacks are required") } - switch opts.Method { - case auth.MethodPassword: - if opts.Authenticate == nil { - return errors.New("password login requires an authenticate callback") - } - case auth.MethodBrowserSession: - if opts.ParseCookies == nil { - return errors.New("browser-session login requires a cookie parser") - } - default: - return fmt.Errorf("unsupported login method %q", opts.Method) - } return nil } diff --git a/internal/tui/login_actions.go b/internal/tui/login_actions.go index 4694317..9f2a1e7 100644 --- a/internal/tui/login_actions.go +++ b/internal/tui/login_actions.go @@ -12,24 +12,6 @@ import ( ) func (m loginModel) submitCredentials() (tea.Model, tea.Cmd) { - if m.opts.Method == auth.MethodBrowserSession { - raw := strings.TrimSpace(m.inputs[0].Value()) - if raw == "" { - m.err = errors.New("paste the Cookie header to continue") - return m, nil - } - value, err := m.opts.ParseCookies(raw) - m.inputs[0].Reset() - if err != nil { - m.err = err - return m, nil - } - m.stage = stageWorking - m.status = "Verifying session…" - m.err = nil - return m, tea.Batch(m.spinner.Tick, m.verify(value)) - } - email := strings.TrimSpace(m.inputs[0].Value()) password := m.inputs[1].Value() if email == "" { diff --git a/internal/tui/login_model.go b/internal/tui/login_model.go index 803f655..56bdc5f 100644 --- a/internal/tui/login_model.go +++ b/internal/tui/login_model.go @@ -62,7 +62,7 @@ type loginModel struct { } func newLoginModel(opts LoginOptions) loginModel { - inputs := newCredentialInputs(opts.Method) + inputs := newCredentialInputs() inputs[0].Focus() indicator := spinner.New(spinner.WithSpinner(spinner.Dot)) @@ -73,12 +73,7 @@ func newLoginModel(opts LoginOptions) loginModel { } } -func newCredentialInputs(method auth.Method) []textinput.Model { - if method == auth.MethodBrowserSession { - cookie := newInput("Cookie header", "session_id=…; csrftoken=…", true) - cookie.CharLimit = session.MaxCookieHeaderBytes - return []textinput.Model{cookie} - } +func newCredentialInputs() []textinput.Model { email := newInput("Email", "you@example.com", false) email.CharLimit = auth.MaxEmailCharacters password := newInput("Password", "", true) diff --git a/internal/tui/login_state.go b/internal/tui/login_state.go index e967dda..d3f9463 100644 --- a/internal/tui/login_state.go +++ b/internal/tui/login_state.go @@ -4,7 +4,6 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/matteing/monarch-cli/internal/auth" "github.com/matteing/monarch-cli/internal/session" ) @@ -43,9 +42,9 @@ func (m loginModel) showMFAForm(err error) (tea.Model, tea.Cmd) { func (m loginModel) showCredentialForm(err error) (tea.Model, tea.Cmd) { email := m.pendingEmail m.clearSecrets() - m.inputs = newCredentialInputs(m.opts.Method) + m.inputs = newCredentialInputs() m.focused = 0 - if m.opts.Method == auth.MethodPassword && email != "" { + if email != "" { m.inputs[0].SetValue(email) m.inputs[0].Blur() m.focused = 1 diff --git a/internal/tui/login_test.go b/internal/tui/login_test.go index 482abc3..808b0cb 100644 --- a/internal/tui/login_test.go +++ b/internal/tui/login_test.go @@ -14,7 +14,6 @@ import ( "charm.land/lipgloss/v2" "github.com/matteing/monarch-cli/internal/apperr" - "github.com/matteing/monarch-cli/internal/auth" "github.com/matteing/monarch-cli/internal/session" ) @@ -27,6 +26,7 @@ func TestLoginCancellationUsesStableApplicationKind(t *testing.T) { func TestRunLoginPreservesEscapeParentCancelAndDeadline(t *testing.T) { start := func(t *testing.T, ctx context.Context) (*io.PipeWriter, <-chan error) { t.Helper() + value := testToken(t, "token") input, keyboard := io.Pipe() t.Cleanup(func() { input.Close() @@ -36,10 +36,12 @@ func TestRunLoginPreservesEscapeParentCancelAndDeadline(t *testing.T) { go func() { _, err := RunLogin(LoginOptions{ Context: ctx, Input: input, Output: io.Discard, - Method: auth.MethodBrowserSession, Profile: "default", - ParseCookies: session.ParseCookieHeader, - Verify: func(context.Context, session.Session) error { return nil }, - Save: func(string, session.Session) error { return nil }, + Profile: "default", + Authenticate: func(context.Context, string, string, string) (session.Session, error) { + return value, nil + }, + Verify: func(context.Context, session.Session) error { return nil }, + Save: func(string, session.Session) error { return nil }, }) done <- err }() @@ -92,7 +94,7 @@ func TestPasswordLoginExplainsSecretStorage(t *testing.T) { } } - model := newLoginModel(LoginOptions{Method: auth.MethodPassword, Profile: "default"}) + model := newLoginModel(LoginOptions{Profile: "default"}) if model.inputs[1].EchoMode != textinput.EchoPassword { t.Fatal("password input is not masked") } @@ -107,24 +109,8 @@ func TestPasswordLoginExplainsSecretStorage(t *testing.T) { } } -func TestBrowserLoginExplainsMinimalCookieStorage(t *testing.T) { - for _, phrase := range []string{"No email or password", "session_id", "csrftoken"} { - if !strings.Contains(BrowserPrivacyNotice, phrase) { - t.Fatalf("browser privacy notice does not contain %q: %s", phrase, BrowserPrivacyNotice) - } - } - - model := newLoginModel(LoginOptions{Method: auth.MethodBrowserSession, Profile: "default"}) - if model.inputs[0].EchoMode != textinput.EchoPassword { - t.Fatal("browser cookie input is not masked") - } - if !strings.Contains(strings.Join(strings.Fields(model.View().Content), " "), "No email or password") { - t.Fatal("browser privacy notice is not visible in the login form") - } -} - func TestLoginResizesInputsControlsAndTinyView(t *testing.T) { - model := newLoginModel(LoginOptions{Method: auth.MethodPassword, Profile: "default"}) + model := newLoginModel(LoginOptions{Profile: "default"}) next, _ := model.Update(tea.WindowSizeMsg{Width: 34, Height: 12}) model = next.(loginModel) if model.inputs[0].Width() != 20 || model.inputs[1].Width() != 20 { @@ -144,7 +130,7 @@ func TestPasswordLoginMFAKeyboardFlow(t *testing.T) { var receivedEmail, receivedPassword, receivedCode string var verified, saved bool opts := LoginOptions{ - Context: context.Background(), Method: auth.MethodPassword, Profile: "default", + Context: context.Background(), Profile: "default", Authenticate: func(_ context.Context, email, password, code string) (session.Session, error) { receivedEmail, receivedPassword, receivedCode = email, password, code if code == "" { @@ -210,7 +196,7 @@ func TestPasswordLoginMFAKeyboardFlow(t *testing.T) { } func TestRepeatedMFARequiredStaysOnMFAForm(t *testing.T) { - model := newLoginModel(LoginOptions{Method: auth.MethodPassword, Profile: "default"}) + model := newLoginModel(LoginOptions{Profile: "default"}) model.pendingEmail = "person@example.com" model.pendingPassword = "secret" mfaErr := apperr.New(apperr.KindMFARequired, "login", "MFA code was rejected", nil) @@ -232,7 +218,7 @@ func TestCancelDuringVerifyCancelsWorkAndNeverSaves(t *testing.T) { started := make(chan struct{}) var saves atomic.Int32 model := newLoginModel(LoginOptions{ - Context: ctx, Method: auth.MethodPassword, Profile: "default", + Context: ctx, Profile: "default", Verify: func(ctx context.Context, _ session.Session) error { close(started) <-ctx.Done() @@ -265,7 +251,7 @@ func TestCanceledContextBeforeSaveCommandNeverCallsSave(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var saves atomic.Int32 model := newLoginModel(LoginOptions{ - Context: ctx, Method: auth.MethodPassword, Profile: "default", + Context: ctx, Profile: "default", Save: func(string, session.Session) error { saves.Add(1) return nil @@ -292,22 +278,25 @@ func TestRunLoginWaitsForStartedCredentialVaultCommit(t *testing.T) { err error } done := make(chan outcome, 1) + value := testToken(t, "token") go func() { - value, err := RunLogin(LoginOptions{ + result, err := RunLogin(LoginOptions{ Context: ctx, Input: input, Output: io.Discard, - Method: auth.MethodBrowserSession, Profile: "default", - ParseCookies: session.ParseCookieHeader, - Verify: func(context.Context, session.Session) error { return nil }, + Profile: "default", + Authenticate: func(context.Context, string, string, string) (session.Session, error) { + return value, nil + }, + Verify: func(context.Context, session.Session) error { return nil }, Save: func(string, session.Session) error { close(started) <-release return nil }, }) - done <- outcome{value: value, err: err} + done <- outcome{value: result, err: err} }() - if _, err := io.WriteString(keyboard, "session_id=sid; csrftoken=csrf\r"); err != nil { + if _, err := io.WriteString(keyboard, "person@example.com\rsecret\r"); err != nil { t.Fatal(err) } select { @@ -324,7 +313,7 @@ func TestRunLoginWaitsForStartedCredentialVaultCommit(t *testing.T) { close(release) select { case result := <-done: - if result.err != nil || result.value.Mode != session.ModeCookie { + if result.err != nil || result.value.Mode != session.ModeToken { t.Fatalf("RunLogin result = mode %q, error %v", result.value.Mode, result.err) } case <-time.After(2 * time.Second): @@ -340,12 +329,15 @@ func TestRunLoginReturnsCancellationAfterStartedCommitFails(t *testing.T) { started := make(chan struct{}) release := make(chan struct{}) done := make(chan error, 1) + value := testToken(t, "token") go func() { _, err := RunLogin(LoginOptions{ Context: ctx, Input: input, Output: io.Discard, - Method: auth.MethodBrowserSession, Profile: "default", - ParseCookies: session.ParseCookieHeader, - Verify: func(context.Context, session.Session) error { return nil }, + Profile: "default", + Authenticate: func(context.Context, string, string, string) (session.Session, error) { + return value, nil + }, + Verify: func(context.Context, session.Session) error { return nil }, Save: func(string, session.Session) error { close(started) <-release @@ -354,7 +346,7 @@ func TestRunLoginReturnsCancellationAfterStartedCommitFails(t *testing.T) { }) done <- err }() - if _, err := io.WriteString(keyboard, "session_id=sid; csrftoken=csrf\r"); err != nil { + if _, err := io.WriteString(keyboard, "person@example.com\rsecret\r"); err != nil { t.Fatal(err) } select { @@ -375,7 +367,7 @@ func TestRunLoginReturnsCancellationAfterStartedCommitFails(t *testing.T) { } func TestLoginShowsRecoverableAndSanitizedInlineErrors(t *testing.T) { - model := newLoginModel(LoginOptions{Method: auth.MethodPassword, Profile: "default"}) + model := newLoginModel(LoginOptions{Profile: "default"}) model.focused = 1 next, command := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) model = next.(loginModel) @@ -390,7 +382,7 @@ func TestLoginShowsRecoverableAndSanitizedInlineErrors(t *testing.T) { } func TestLoginProjectsVisibleInputWithoutChangingCredentials(t *testing.T) { - model := newLoginModel(LoginOptions{Method: auth.MethodPassword, Profile: "default"}) + model := newLoginModel(LoginOptions{Profile: "default"}) raw := "\u0301person\u202e@example.com" model.inputs[0].SetValue(raw) model.inputs[0].Blur() @@ -427,7 +419,7 @@ func TestLoginProjectsVisibleInputWithoutChangingCredentials(t *testing.T) { func TestValidateLoginOptions(t *testing.T) { valid := LoginOptions{ Context: context.Background(), Input: strings.NewReader(""), Output: &strings.Builder{}, - Method: auth.MethodPassword, Profile: "default", + Profile: "default", Authenticate: func(context.Context, string, string, string) (session.Session, error) { return testToken(t, "token"), nil }, @@ -440,7 +432,6 @@ func TestValidateLoginOptions(t *testing.T) { for name, mutate := range map[string]func(*LoginOptions){ "context": func(opts *LoginOptions) { opts.Context = nil }, "io": func(opts *LoginOptions) { opts.Output = nil }, - "method": func(opts *LoginOptions) { opts.Method = "unknown" }, "profile": func(opts *LoginOptions) { opts.Profile = "../bad" }, "auth": func(opts *LoginOptions) { opts.Authenticate = nil }, "verify": func(opts *LoginOptions) { opts.Verify = nil }, diff --git a/internal/tui/login_view.go b/internal/tui/login_view.go index 15c18bf..c6430ea 100644 --- a/internal/tui/login_view.go +++ b/internal/tui/login_view.go @@ -8,7 +8,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/matteing/monarch-cli/internal/auth" "github.com/matteing/monarch-cli/internal/textsafe" ) @@ -19,13 +18,7 @@ func (m loginModel) View() tea.View { gap = "\n" } - if m.opts.Method == auth.MethodBrowserSession { - body.WriteString(mutedStyle.Render("Copy the Cookie request header from a signed-in app.monarch.com tab.")) - body.WriteString("\n") - body.WriteString(mutedStyle.Render(BrowserPrivacyNotice)) - } else { - body.WriteString(mutedStyle.Render(PasswordPrivacyNotice)) - } + body.WriteString(mutedStyle.Render(PasswordPrivacyNotice)) body.WriteString(gap) if m.stage == stageWorking || m.stage == stageSaving {