Menu

#338 feat(integrations): in-process OAuth + in-app token connect for all 5 trackers

closed
nobody
None
2026-06-25
2026-06-25
Anonymous
No

Originally created by: Akarsh-Hegde

Summary

Centralized PM integrations system supporting all 5 trackers (Jira, Linear, GitHub, Trello, Azure DevOps) working perfectly from both the setup wizard and dashboard.

Key changes

  • New shared crate meridian-oauth — config-free OAuth/token engine reused by both daemon and tray
  • In-process OAuth — tray runs Jira/Trello browser login directly (no subprocess); GitHub keeps gh-CLI
  • Token-based connect — new save_integration_token command eliminates "run meridian config edit" dead-end
  • Centralized UI — single <ConnectTrackers> component driven by ui/lib/integrations.ts SSOT
  • Jira dual-path — Browser OAuth for Cloud, API token for Cloud (self-hosted support future)

Fixes

  • Token-connected Jira is now disconnectable (was broken, only OAuth path worked)
  • Jira self-hosted copy is honest — states "Cloud only, self-hosted not yet supported"

Testing

  • All 5 trackers have clean connect flows (token or OAuth as applicable)
  • Token path works in all builds without env secrets
  • OAuth path tested in live tray dev (Jira 401 is expected without secret — works in CI builds)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
  • Added a unified integrations experience for connecting Jira, Trello, GitHub, Linear, and Azure DevOps.
  • Jira and Trello can now connect directly in the app, with token saving and reconnect support.
  • Added support for managing provider credentials and refreshing connections from the UI.

  • Bug Fixes

  • Improved disconnect behavior so credentials are removed more reliably.
  • Fixed integration status refreshes so connected services stay up to date after changes.

Related

Tickets: #341
Tickets: #342
Tickets: #343
Tickets: #347
Tickets: #351

Discussion

  • Anonymous

    Anonymous - 2026-06-25

    Originally posted by: coderabbitai[bot]

    [!IMPORTANT]

    Review skipped

    Auto reviews are disabled on base/target branches other than the default branch.

    Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.


    ⚙️ Run configuration

    Configuration used: Organization UI

    Review profile: ASSERTIVE

    Plan: Pro Plus

    Run ID: a8688b7e-9c68-4c04-b165-d5c84519fa1a

    You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

    Use the checkbox below for a quick retry:
    - [ ] 🔍 Trigger review

    ✨ Finishing Touches
    🧪 Generate unit tests (beta) - [ ] Create PR with unit tests - [ ] Commit unit tests in branch `feat/in-process-oauth`

    Comment @coderabbitai help to get the list of available commands.

     
  • Anonymous

    Anonymous - 2026-06-25

    Originally posted by: Akarsh-Hegde

    Code Review — 10-angle automated analysis

    15 findings (10 bugs, 1 cleanup across meridian-oauth, tray/src-tauri/src/commands/integrations.rs, ui/components/IntegrationConnect.tsx)


    🔴 Critical / High bugs

    1. jira.rs:219ensure_fresh discards rotated refresh token on save failure → permanent lockout
    Atlassian rotates the refresh token on every use. If flow::refresh() succeeds but store::save(&t) fails (disk full, permissions), the new token is discarded and the store retains the now-invalidated old token. Every subsequent call gets a 401 — permanent lockout until re-auth. Fix: write new token to temp file and rename-into-place before returning success.

    2. jira.rs:28refresh_lock() is per-process — daemon + tray race consumes same Atlassian refresh token
    The OnceLock<Mutex<()>> is a per-process static. Daemon background sync and a tray user action can both call ensure_fresh() simultaneously, both read the expired store, both POST to Atlassian — the second POST 401s because the first call already rotated and consumed the token. Fix: serialize across processes with a file lock on the OAuth store path.

    3. flow.rs:39TokenResponse.expires_in missing #[serde(default)] — login fails hard if Atlassian omits the field
    expires_in is a required serde field. Atlassian can omit it for certain grant types or partial error responses. serde_json::from_str then returns a parse error, failing the entire login. #[serde(default)] degrades gracefully: expires_at = now, triggering an immediate refresh on next use rather than a hard failure.

    4. integrations.rs:328save_integration_token clears OAuth JSON but not the error sentinel — stale sentinel corrupts get_oauth_status
    If Jira OAuth fails (sentinel written), then user connects via API token, the sentinel is never cleared. get_oauth_status still returns the old error and the dashboard shows a broken state even though the token is working. Fix: have save_integration_token delete the error sentinel for the provider when it writes a successful token.

    5. jira.rs:157 — Jira and Trello share fixed loopback port 9123 — concurrent flows fail with EADDRINUSE
    Both providers call flow::run_authcode_flow on port 9123. If user clicks Connect Jira then Connect Trello (or double-clicks), the second TcpListener::bind returns EADDRINUSE, the spawned task writes the error sentinel. Fix: bind port 0 and derive the redirect URI from the OS-assigned port; add a per-provider AtomicBool in-flight guard.

    6. integrations.rs:594 — inner task _ => Ok(()) catch-all silently no-ops new providers
    If a developer adds a new provider to OAUTH_PROVIDERS but forgets to update the inner match, the spawned task returns Ok(()) without running any login. The user sees started=true but no token is ever written. Fix: replace catch-all with bail!("unhandled provider: {}", task_provider).

    7. integrations.rs:1296forward_oauth_env calls std::env::set_var on a Tokio worker thread — UB under concurrent access
    set_var/getenv are not thread-safe on POSIX. Concurrent getenv (reqwest proxy detection, a second OAuth flow) races with set_var. Rust 1.93+ warns on set_var in multithreaded programs. Fix: read env keys inside the spawned async block after spawn, or pass values as captured variables rather than mutating the global env.

    8. jira.rs:194 — Empty OAuth client secret accepted silently until mid-flow
    When MERIDIAN_JIRA_OAUTH_CLIENT_SECRET is not set at build time, DEFAULT_CLIENT_SECRET = "". The Connect button shows, the user clicks it, and only then do they get a cryptic error. CLAUDE.md: "Validate all input at system boundaries." Fix: disable/hide the Jira OAuth button or log a startup warning when the compiled-in secret is empty.


    🟡 Medium bugs

    9. integrations.rs:1077 — blocking std::fs I/O inside async Tauri command
    upsert_env calls std::fs::read_to_string/std::fs::write synchronously on Tokio's worker thread inside save_integration_token. On a slow disk this starves the poll loop, health refresh, and active-session updates. Fix: tokio::fs equivalents or tokio::task::spawn_blocking.

    10. IntegrationConnect.tsx:1995pollRef.current assigned after await — interval leaks on unmount
    pollRef.current = id is assigned only after await mutate(...) resolves. If the component unmounts while that await is in flight, the cleanup useEffect runs before the assignment — clearInterval is never called and the 2-second poll fires indefinitely. Fix: assign the interval id before the first await.

    11. IntegrationConnect.tsx:2030tracker.token! non-null assertion throws if method is undefined
    const method = tracker.token! will throw a runtime TypeError if TokenSetup is ever rendered for a tracker that has no token method in the config. Fix: null-check or optional chaining.


    🟠 Cleanup / conventions

    12. integrations.rs:80 — hand-rolled parse_env/upsert_env diverge from dotenvy
    The daemon loads ~/.meridian/.env via dotenvy::dotenv_override() which handles edge cases (export prefix, backslash continuation, quoted values). The tray's hand-rolled parser doesn't — a value like KEY="foo bar" is read differently by the two processes. Fix: use dotenvy in both, or ensure the hand-rolled parser handles all cases dotenvy does.

    13. jira.rs:275discover_cloud error message on non-2xx is generic — actual Atlassian error is swallowed
    On 401/403, resp.text() is called but the body is not included in the returned error message. Fix: bail!("accessible-resources → {status}: {text}").

    14. jira.rs:281discover_cloud silently picks the first cloud site — wrong instance for multi-org users
    When an Atlassian account has multiple accessible sites, the first is taken silently (only a warning logged). A user in multiple orgs may be connected to the wrong Jira instance with no recourse. Future: surface site selection in the setup wizard.

    15. integrations.rs:238strip_env_keys missing trailing newline — corrupts next upsert_env append
    kept.join("\n") produces no trailing newline. The next upsert_env call appends \nKEY=value onto the last retained key line, producing LAST_KEY=valuenewKEY=newvalue — corrupting the env file. Fix: kept.join("\n") + "\n" (or kept.join("\n") + if kept.is_empty() { "" } else { "\n" }).


    Findings [#3], 4, 6, 7, 9, 10, 11, 13, 15 are being addressed in a follow-up commit on this branch. Findings [#1], 2, 5, 8, 12, 14 are architectural — raised for awareness; fixes scoped separately.

     

    Related

    Tickets: #1
    Tickets: #3

  • Anonymous

    Anonymous - 2026-06-25

    Originally posted by: Akarsh-Hegde

    Review follow-up — fixes applied (commit 897b099)

    The following findings from the automated review have been addressed:

    # Finding Fix
    3 TokenResponse.expires_in missing #[serde(default)] — hard login failure if Atlassian omits field Added #[serde(default)]; default 0 → immediate refresh on next use instead of a parse error
    4 save_integration_token didn't clear OAuth error sentinel → stale error shown after token connect Sentinel is now cleared in save_integration_token alongside the OAuth JSON removal
    6 _ => Ok(()) catch-all in inner OAuth task match silently no-ops new providers Replaced with bail!("unhandled OAuth provider: {task_provider}")
    10 OAuthSetup poll interval leaks on unmount (component unmounts during await mutate, cleanup runs before pollRef is assigned) Added mountedRef guard — if unmounted during the await, setInterval is never called
    11 tracker.token! non-null assertion in TokenSetup throws TypeError if rendered for a tracker without a token method Replaced with null check + early return

    Not addressed in this PR (architectural, need separate scope):

    • #1ensure_fresh loses rotated token on save failure → atomic write pattern needed
    • #2refresh_lock() is per-process → cross-process file lock needed to prevent daemon+tray token race
    • #5 — Jira/Trello share fixed port 9123 → bind port 0 + per-provider in-flight guard
    • #7forward_oauth_env calls set_var on Tokio worker thread → pass env values explicitly (requires meridian_oauth API change)
    • #8 — Empty client secret not surfaced until mid-flow → startup validation
    • #9 — Blocking std::fs in upsert_env inside async context → tokio::fs or spawn_blocking
    • #12 — Hand-rolled parse_env/upsert_env diverge from dotenvy → unify
    • #14 — Multi-site Atlassian accounts silently pick first site → surface selection in setup wizard

    Finding #13 was a false positive — discover_cloud already includes the response body in its bail! message.

    PR is ready for review.

     
  • Anonymous

    Anonymous - 2026-06-25

    Ticket changed by: Akarsh-Hegde

    • status: open --> closed
     

Log in to post a comment.