Skip to content

fix(channels/discord): convert upstream 401/403 to domain-scoped error so card click can't sign user out (#2285)#2376

Merged
graycyrus merged 1 commit into
tinyhumansai:mainfrom
CodeGhost21:fix/2285-discord-401-domain-scope
May 22, 2026
Merged

fix(channels/discord): convert upstream 401/403 to domain-scoped error so card click can't sign user out (#2285)#2376
graycyrus merged 1 commit into
tinyhumansai:mainfrom
CodeGhost21:fix/2285-discord-401-domain-scope

Conversation

@CodeGhost21
Copy link
Copy Markdown
Contributor

@CodeGhost21 CodeGhost21 commented May 20, 2026

Summary

Problem

Repro from the issue: a user with a Discord connection clicks the Discord card → modal opens → `DiscordServerChannelPicker` calls `channelConnectionsApi.listDiscordGuilds()` → `openhuman.channels_discord_list_guilds` RPC → Rust `discord_list_guilds` → `api::list_bot_guilds` → Discord REST returns 401 (token rotated / app disabled) → `anyhow::bail!` with the literal "Discord list guilds failed (401 Unauthorized): {body}" → JSON-RPC error result → `is_session_expired_error` matches → `DomainEvent::SessionExpired` published → `SessionExpiredSubscriber` clears the OpenHuman session → user bounced back to Welcome / Google sign-in.

Acceptance criterion 1 ("triggering RPC identified") → `openhuman.channels_discord_list_guilds`, plus its siblings (`channels_discord_list_channels`, `channels_discord_check_permissions`) which call `get_member_info`, `get_guild_roles`, and `get_channel` and share the same failure mode.

Solution

Discord API client

New helper `format_discord_http_error(endpoint, status, body)` in `src/openhuman/channels/providers/discord/api.rs`. On 401 / 403 it emits:

Discord list_guilds: bot token was rejected (upstream HTTP four-oh-one). Open Settings → Channels → Discord and rotate / reconnect the bot token.

The substrings `"401"` and `"unauthorized"` are both absent; the global `is_session_expired_error` classifier returns `false` for this string. Other 4xx/5xx statuses pass through with the legacy verbose format — they don't match the classifier even verbatim.

All six `anyhow::bail!` sites in `api.rs` route through the helper: `list_bot_guilds_at_base`, `list_guild_channels_at_base`, plus the three inner calls inside `check_channel_permissions_at_base` (`get_bot_user`, `get_member_info`, `get_guild_roles`) and `get_channel`. The 401/403 path deliberately omits the upstream body from the user-facing message — Discord's auth-error bodies typically contain the literal words "401" and "Unauthorized", which would smuggle the cascade trigger back in. The body is still in `tracing::debug!` logs immediately above each call site for triage.

Scope coordination

PRs #2292, #2302, #2356 each take a different swing at narrowing `is_session_expired_error` itself (the root cause). This PR is defense-in-depth at the Discord domain — useful even if all three of those land, because:

  1. It identifies the specific RPC the issue traced (acceptance Feat/gitbooks #1).
  2. It surfaces a Discord-actionable remediation in the error string (acceptance Refactor testing scripts in package.json and update dependencies #4).
  3. It adds a cross-module regression test that pins both sides of the contract (acceptance sync branches #5).
  4. The new file footprint (`api.rs`, `api_tests.rs`, `jsonrpc_tests.rs`) does not overlap with the root-cause PRs' touch sites (`src/core/jsonrpc.rs`).

Submission Checklist

If a section does not apply to this change, mark the item as `N/A` with a one-line reason.

  • Tests added or updated (happy path + at least one failure / edge case) — 4 new tests; `cargo test --lib discord::api is_session_expired_error` → 39 passed.
  • Diff coverage ≥ 80% — every changed line in `api.rs` is covered by a new mock-server test or an updated existing one. `jsonrpc_tests.rs` change is test-only.
  • Coverage matrix updated — N/A: behaviour-only change to an existing controller surface; no new feature row.
  • All affected feature IDs from the matrix are listed in `## Related` — N/A: no matrix row touched.
  • No new external network dependencies introduced — uses the existing wiremock/axum mock-server pattern already in `api_tests.rs`.
  • Manual smoke checklist updated if this touches release-cut surfaces — N/A: error-message wording only; UI flow unchanged when the bot token is valid.
  • Linked issue closed via `Closes #NNN` in the `## Related` section — see below.

Impact

  • Runtime/platform: desktop Channels page; backend Discord controller.
  • User-visible: when the Discord bot token is stale, the user now sees a Discord-actionable error in the card and stays signed in to OpenHuman — instead of being logged out.
  • Performance / security: no new network calls; no security-policy change. Discord 401 bodies are no longer spliced into the user-facing message, which incidentally reduces the chance of leaking upstream payloads through error strings.
  • Migration / compatibility: error wording for Discord 401/403 changed. Three existing api_tests.rs assertions were updated; one was renamed to reflect the new contract. No external consumer pins the exact string.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

Commit & Branch

  • Branch: `fix/2285-discord-401-domain-scope` (branched from `origin/main`, i.e. tinyhumansai/openhuman:main, after a fresh `git fetch`)
  • Commit SHA: see PR head

Validation Run

  • `pnpm --filter openhuman-app format:check` — N/A: no frontend changes.
  • `pnpm typecheck` — N/A: no TypeScript changes.
  • Focused tests: `cargo test --lib discord::api is_session_expired_error` → 39 passed, 0 failed.
  • Rust fmt/check (if changed): `cargo fmt --manifest-path Cargo.toml` applied; `cargo check` clean.
  • Tauri fmt/check (if changed): N/A: no Tauri shell changes.

Validation Blocked

  • `command:` N/A
  • `error:` N/A
  • `impact:` N/A

Behavior Changes

  • Intended behavior change: a Discord upstream 401/403 (stale bot token) no longer triggers the JSON-RPC session-expired cascade. Card-click logout when Discord credentials are invalid is gone.
  • User-visible effect: the Discord card now shows an inline error ("bot token was rejected — Settings → Channels → Discord") and the OpenHuman session stays alive.

Parity Contract

  • Legacy behavior preserved: happy path (200 with valid bot token) unchanged. 4xx (non-auth) and 5xx errors keep the same verbose `Discord failed (): ` format they had before.
  • Guard/fallback/dispatch parity checks: cross-module regression test `is_session_expired_error_skips_discord_rewrap_for_2285` pins both sides of the contract (Discord wording AND classifier behaviour) so any future drift fails CI.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved Discord authentication error messages to prevent false session-expired logouts
    • Error messages now display which specific Discord operation failed and guide users to reconnect the bot token via Settings → Channels → Discord
  • Tests

    • Added regression and unit tests for session expiration and Discord error-handling scenarios

Review Change Stack

…r so card click can't sign user out (tinyhumansai#2285)

The Channels page's Discord card opens a setup modal that calls
`channels_discord_list_guilds` → `Discord REST /users/@me/guilds`.
When the bot token is stale/revoked, Discord returns 401 and the
client `bail!`s the literal "Discord list guilds failed (401
Unauthorized): {body}". That string flows up through JSON-RPC as
`Err(String)` and trips
`src/core/jsonrpc.rs::is_session_expired_error`, which classifies
ANY `lower.contains("401") && lower.contains("unauthorized")` as
backend session expiry — publishing `DomainEvent::SessionExpired`
and signing the user out of OpenHuman over a *Discord* credentials
problem.

Three in-flight PRs (tinyhumansai#2292, tinyhumansai#2302, tinyhumansai#2356) all attempt to narrow
`is_session_expired_error` itself. This PR takes the orthogonal
defense-in-depth approach: a Discord-domain rewrap so even if none
of those broader fixes land, the Channels page is safe.

Discord API client (src/openhuman/channels/providers/discord/api.rs)
- New helper `format_discord_http_error(endpoint, status, body)`
  that detects 401/403 and emits a user-facing message that:
    1. does NOT contain "401" or "unauthorized" as substrings
       (so `is_session_expired_error` returns false),
    2. names the failed endpoint (`list_guilds` / `list_channels` /
       `get_member_info` / `get_guild_roles` / `get_bot_user` /
       `get_channel`) so triage can read it from logs,
    3. ends with the actionable
       `Settings → Channels → Discord` remediation path.
  Spells the HTTP code as "four-oh-one"/"four-oh-three" and uses
  "rejected"/"forbidden" — both are user-visible equivalents that
  don't match the classifier.
- All 6 `anyhow::bail!` sites in `api.rs` route through the helper:
  list_bot_guilds_at_base, list_guild_channels_at_base,
  check_channel_permissions_at_base (3 call sites:
  get_bot_user, get_member_info, get_guild_roles), get_channel.
- 401/403 path deliberately omits the upstream body from the
  user-facing message — Discord's auth-error bodies often include
  the literal words "401" and "Unauthorized", which would
  re-introduce the cascade trigger. The body is still in
  `tracing::debug!` logs above each call site for triage.

Tests
- `discord/api_tests.rs`:
  * Replaced `list_bot_guilds_errors_on_non_success_status` with
    `list_bot_guilds_rewraps_401_so_global_session_cascade_does_not_fire`
    — drives a mock Discord that returns the canonical
    `{"message":"401: Unauthorized","code":0}` body and asserts:
      - the rewrapped error contains neither "401" nor "unauthorized"
      - it preserves the `list_guilds` endpoint identifier
      - it carries the `Settings → Channels → Discord` remediation
  * Added `list_bot_guilds_5xx_still_carries_raw_status` — non-auth
    errors keep the verbose status format (they don't trip the
    session classifier anyway).
  * Replaced `list_guild_channels_errors_on_non_success_status`
    with `list_guild_channels_rewraps_403_with_remediation_and_no_session_keywords`.
  * Updated `check_channel_permissions_errors_on_member_lookup_failure`
    to assert the new endpoint identifier `get_member_info` + the
    absence of "401" / "unauthorized" substrings.
- `core/jsonrpc_tests.rs`:
  * Added `is_session_expired_error_skips_discord_rewrap_for_2285`
    — pins the canonical 401 AND 403 rewrap message bodies and
    asserts `is_session_expired_error` returns `false` for both,
    so either-side drift (Discord wording change OR classifier
    broadening) fails loudly in CI.

`cargo test --lib discord::api is_session_expired_error` →
31 + 8 = 39 passed, 0 failed (4 new). `cargo check` clean.
`cargo fmt` applied.

Out of scope (separate / complementary PRs)
- No FE change: `DiscordConfig` already surfaces RPC errors via
  its existing `setError` path; the new domain-specific message
  bubbles through unchanged.
- No change to `is_session_expired_error` itself: that's owned by
  the in-flight root-cause PRs (tinyhumansai#2292 / tinyhumansai#2302 / tinyhumansai#2356). This PR
  is defense-in-depth and remains useful even if all three of
  those land.
@CodeGhost21 CodeGhost21 requested a review from a team May 20, 2026 21:21
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0804da6b-4450-4e5e-8702-0cba87e8c65c

📥 Commits

Reviewing files that changed from the base of the PR and between fa8d75f and a13c05d.

📒 Files selected for processing (3)
  • src/core/jsonrpc_tests.rs
  • src/openhuman/channels/providers/discord/api.rs
  • src/openhuman/channels/providers/discord/api_tests.rs

📝 Walkthrough

Walkthrough

This PR fixes issue #2285 by preventing Discord HTTP 401/403 authentication errors from triggering the global session-expired logout cascade. A new format_discord_http_error helper centralizes error formatting, deliberately omitting substrings that would match the upstream is_session_expired_error classifier. All Discord API call sites integrate this helper, and regression tests pin the behavior at both API and JSON-RPC layers.

Changes

Discord auth-error rewrap for session-expired fix

Layer / File(s) Summary
Error formatter helper
src/openhuman/channels/providers/discord/api.rs
format_discord_http_error rewraps Discord 401/403 responses with user guidance (Settings → Channels → Discord) while omitting keywords ("401", "403", "unauthorized") that would trigger is_session_expired_error. Other statuses include the code and endpoint identifier.
Discord API call integration
src/openhuman/channels/providers/discord/api.rs
Six Discord API error paths now use format_discord_http_error with endpoint identifiers (list_guilds, list_channels, get_bot_user, get_member_info, get_guild_roles, get_channel) to disambiguate failures.
Discord API error-handling tests
src/openhuman/channels/providers/discord/api_tests.rs
New and updated test cases verify 401 and 403 responses are rewrapped correctly (absent session-expired keywords, present endpoint identifier and remediation path), 5xx responses preserve status codes, and permission-check assertions match the rewrap format.
JSON-RPC session-expired regression test
src/core/jsonrpc_tests.rs
Regression test is_session_expired_error_skips_discord_rewrap_for_2285 pins canonical Discord-rewrapped error variants (401 and 403 styles) and verifies they do not match is_session_expired_error, preventing future text drift.

Sequence Diagram

sequenceDiagram
  participant DiscordCardClick as Discord Card Click
  participant DiscordAPI as Discord API Call
  participant FormatHelper as format_discord_http_error
  participant ErrorClassifier as is_session_expired_error
  participant UIHandler as UI Error Handler

  DiscordCardClick->>DiscordAPI: list_guilds / check_channel_permissions
  DiscordAPI-->>FormatHelper: HTTP 401/403 response
  FormatHelper->>FormatHelper: Omit "401"/"unauthorized" substrings
  FormatHelper->>FormatHelper: Include remediation path
  FormatHelper-->>DiscordAPI: Rewrapped error message
  DiscordAPI-->>ErrorClassifier: Is this session-expired?
  ErrorClassifier-->>ErrorClassifier: Check for 401/unauthorized keywords
  ErrorClassifier-->>UIHandler: No match → show Discord settings prompt
  UIHandler->>UIHandler: Display recoverable error (not logout)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • tinyhumansai/openhuman#1763: Adjusts agent-layer session-expired classification and documents the JSON-RPC matcher; both PRs address the same session-expired/401 misclassification root cause.
  • tinyhumansai/openhuman#1719: Uses the session-expired classifier and Sentry suppression; both PRs coordinate around preventing Discord auth errors from triggering unwanted session-expired behavior.

Suggested labels

working

Suggested reviewers

  • senamakel
  • graycyrus

Poem

🐰 A Discord auth hiccup once logged folks right out,
So we wrapped up those 401s with careful intent—
Hide "unauthorized" keywords, keep guidance about
The token-rotation path, and the logout is spent! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: converting upstream Discord 401/403 errors into domain-scoped messages to prevent unintended user sign-out, directly addressing issue #2285.
Linked Issues check ✅ Passed The PR comprehensively addresses all coding-related objectives from #2285: identifies the RPC path (Discord API calls), prevents card-click logout via error rewrap, adds targeted tests for Discord 401/403 handling, and includes regression coverage with specific test assertions.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the Discord 401/403 handling issue: new error helper, routing existing error sites through it, and adding focused tests—no unrelated refactoring or unrelated feature changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai Bot added the working A PR that is being worked on by the team. label May 20, 2026
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, nice work!

@graycyrus graycyrus merged commit 61dd544 into tinyhumansai:main May 22, 2026
30 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord channel card click logs the user out

3 participants