Skip to content

fix(tv-roku): recover from a rejected session instead of stranding the user - #70

Draft
beckness wants to merge 1 commit into
Quixomatic:mainfrom
beckness:fix/roku-auth-recovery
Draft

beckness wants to merge 1 commit into
Quixomatic:mainfrom
beckness:fix/roku-auth-recovery

Conversation

@beckness

Copy link
Copy Markdown
Contributor

What & why

Once the Roku client's stored bearer token stops being honoured (sessions expire after 30 days unused, or get revoked), the client can't be used again without reinstalling:

  • Every /api/v1 request returns 401. The guide shows "Couldn't load the guide. Check your connection and try again.", which treats an auth failure as a network one and offers no retry.
  • Settings and Account in the sidebar silently do nothing, so the user can't even reach Sign out.

The second bug is the real trap. GuideSidebar.init() only called refresh(). Its item list (m.items) was built only by rebuild(), which runs on a packages or lens change. lens starts at "all", and the Guide only ever sets it to "all" again, which doesn't fire onChange. So rebuild() ran only if /api/v1/packages succeeded. When that request 401s (or the server is offline), m.items stays empty and onActivate() drops every OK (sel >= m.items.count()). The circles still render and the focus ring still moves (itemCount defaults to 3), so the sidebar looks fine but does nothing. The guide itself never checked for 401 either, unlike tv-web (guide-screen.tsx forces sign-out on a guide 401).

This change:

  • Sidebar: builds its items in init() (rebuild()), so Guide, Settings and Account work whether or not packages load.
  • One error classifier: Api.errorKind(status) classifies failures as network (no HTTP status: unreachable, DNS, TLS, timeout), auth (401), forbidden (403), server (5xx) or client. Every rejection now carries kind.
  • One handler for session rejection: Api_onResponse bumps m.global.authRejected on a definitive invalid-session 401. That means a bearer-carrying /api/v1/* request, rejected by the * middleware in apps/server/src/rest.ts, for the current token. The sign-in endpoints (/api/tv/auth/*, /api/auth/device/*), which 401 for a wrong password or pending code, are excluded, and so are stale requests carrying a token that has since been replaced. MainScene.onAuthRejected clears only the token, keeps the server URL, tears down the player and routes to Login with a "Your session expired" notice. It's idempotent: the token is gone after the first signal, so the parallel 401s from favorites, packages and heartbeat are no-ops and can't loop. A network failure never reaches it, so a flaky connection can't sign anyone out.
  • Guide first-load failure: shows a message specific to the failure kind and retries, either on OK or on the existing 60s timer, instead of dead-ending. A 403 is explained and leaves the credentials alone.
  • Diagnostic: no longer marks caps done when the manifest request 401s. Before, re-signing in would skip the diagnostic for that server.
  • Settings → User: shows "Session expired" (get-session returns 200 + null for a dead token) or a load error instead of a permanent "…". Sign out was always local and still works.

No server or API changes. The fix uses the existing 401/403 contract and the existing Login / device-pairing flows.

Area

  • Server / admin (backend, channels, scheduling, AI lineup, imports)
  • A TV / player client (Apple TV, Fire TV, Android TV, Roku, webOS, tv-web)
  • Desktop server app (Windows / macOS / Linux)
  • Docs / website (getairwave.tv)
  • CI / tooling

Testing

These are three separate claims. Only the first two have been established.

  1. Static: verified. pnpm --filter tv-roku build (bsc + bslint) exits 0. The only diagnostics are the two BSLINT1005 warnings that already exist on main (ChannelRow.bs, Guide.bs). bsfmt makes the same number of changes to every touched .bs file before and after this PR, so the PR adds no formatting violations.
  2. Logic: verified off-device. I ran the transpiled Api_errorKind / Api_isSessionRejection under a BrightScript interpreter (brs-node CLI, out of tree): 18/18 cases pass. They cover status 0 / negative curl codes → network; 401 / 403 / 5xx / 4xx; bearer vs no bearer; /api/tv/auth/password and /api/auth/device/token 401s excluded; a stale token after re-login; already signed out. The harness exits non-zero on any failure, and I confirmed it goes red with a deliberately broken case. tv-roku has no in-repo test harness, so this is not committed. Adding Rooibos or similar is out of scope here.
  3. Integration: NOT verified. No physical Roku or SceneGraph harness was used. These still need on-device qualification:
    • an expired or revoked token at launch lands on Login with the notice and the server URL kept;
    • signing in again reaches the guide without reinstalling;
    • Settings and Account open from the sidebar while the guide is failing;
    • OK and the 60s timer retry after a network or server failure without touching the token;
    • a 401 mid-playback tears down the player cleanly;
    • no route/observer re-entrancy issues when several 401s land in the same frame.

The interpreter run doesn't count as evidence for any of those.

I'm opening this as a draft until an on-device sideload run covers item 3.

Checklist

  • Typechecks / builds locally (pnpm --filter tv-roku build)
  • I did not bump versions or tag (maintainer handles releases)
  • No secrets, certs, or .env values committed

🤖 Generated with Claude Code

…e user

An expired/revoked bearer left the Roku client unusable: every /api/v1 call
401'd, the guide showed "Check your connection", and Settings/Account in the
sidebar silently did nothing — so the user could not even sign out.

- GuideSidebar built its item list only on a packages/lens change. With
  /api/v1/packages failing (and `lens` never changing from its "all"
  default), m.items stayed empty and onActivate dropped every OK. Build it
  in init so the action circles always work.
- Classify request failures centrally (Api.errorKind → network / auth /
  forbidden / server / client) and carry `kind` on every rejection.
- A definitive 401 from a bearer /api/v1 request (for the current token) is
  signalled once from Api_onResponse; MainScene clears only the token, keeps
  the server URL, tears down the player and routes to Login with a "session
  expired" notice. Idempotent across the parallel 401s; network failures
  never clear credentials. Matches tv-web's forced sign-out on a guide 401.
- Guide first-load failures say what failed and retry (OK, or the 60s timer)
  instead of dead-ending; 403 is explained without touching credentials.
- Diagnostic no longer marks caps done when the manifest 401s.
- Settings → User shows "Session expired" / load errors instead of "…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
airwave-site Skipped Skipped Oct 17, 2026 9:50pm UTC

@beckness

Copy link
Copy Markdown
Contributor Author

Cross-ref: #71 fixes the same failure, a TV client not recovering once the server rejects its session (401), on tv-native (Apple TV / Android TV). The two PRs are independently scoped and can be reviewed and merged separately; neither depends on the other. This one stays a draft until it's been validated on a physical Roku.

This branch was previously deployed

1 inactive deployment
Preview — e5e28bba Deployed Oct 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant