fix(auth): request moderator OAuth scopes only on opt-in

Stop asking for mod scopes at normal login so existing core-only tokens
never hit a missing-permissions dialog. Gate moderated-channels Helix,
offer an Enable flow for mods/broadcasters, and upgrade atomically.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tommy Chow 2026-08-09 15:33:06 -07:00
parent 2d65aeec29
commit cc772a7c90
15 changed files with 690 additions and 128 deletions

View file

@ -1,4 +1,7 @@
/// OAuth scopes Frosty requests for a logged-in Twitch user session.
/// Core OAuth scopes Frosty requests on a normal Twitch login.
///
/// Matches the pre-moderator-tools set so existing tokens remain valid without
/// a re-consent nag for non-moderators.
const twitchUserScopes = <String>[
'chat:read',
'chat:edit',
@ -6,15 +9,28 @@ const twitchUserScopes = <String>[
'user:read:blocked_users',
'user:manage:blocked_users',
'user:manage:chat_color',
];
/// Moderator-only scopes requested via an explicit opt-in upgrade.
const twitchModeratorScopes = <String>[
'user:read:moderated_channels',
'moderator:manage:chat_messages',
'moderator:manage:banned_users',
];
/// Space-separated scope string for Twitch's authorize URL.
/// Core + moderator scopes for an opted-in moderator session.
const twitchAllUserScopes = <String>[
...twitchUserScopes,
...twitchModeratorScopes,
];
/// Space-separated scope string for Twitch's authorize URL (core only).
String get twitchUserScopeQuery => twitchUserScopes.join(' ');
/// Human-readable labels for [twitchUserScopes].
/// Space-separated scope string for Twitch's authorize URL (core + mod).
String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' ');
/// Human-readable labels for Frosty-requested scopes.
const twitchScopeLabels = <String, String>{
'chat:read': 'Read chat messages',
'chat:edit': 'Send chat messages',
@ -27,6 +43,13 @@ const twitchScopeLabels = <String, String>{
'moderator:manage:banned_users': 'Timeout and ban users as a moderator',
};
/// Whether [granted] includes every moderator scope Frosty needs.
bool hasModeratorScopes(Iterable<String> granted) =>
missingTwitchScopes(
granted: granted,
required: twitchModeratorScopes,
).isEmpty;
/// Scopes in [required] that are not present in [granted].
List<String> missingTwitchScopes({
required Iterable<String> granted,
@ -36,7 +59,15 @@ List<String> missingTwitchScopes({
return required.where((scope) => !grantedSet.contains(scope)).toList();
}
/// Dialog body for a Twitch Helix/OAuth 401.
String _scopeBullets(Iterable<String> scopes) => scopes
.map((scope) => '• ${twitchScopeLabels[scope] ?? scope}')
.join('\n');
/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request.
///
/// Missing-permissions copy only lists **core** scopes. A token that already
/// has core scopes is treated as expired/invalid rather than "missing mod
/// permissions" so non-mods are never nagged about moderator consent.
String unauthorizedDialogMessage({
required bool isLoggedIn,
required Iterable<String> grantedScopes,
@ -47,14 +78,17 @@ String unauthorizedDialogMessage({
final missing = missingTwitchScopes(granted: grantedScopes);
if (missing.isEmpty) {
return 'Your session is missing permissions. Please log in again to continue.';
return 'Your session has expired. Please log in again to continue.';
}
final bullets = missing
.map((scope) => '• ${twitchScopeLabels[scope] ?? scope}')
.join('\n');
return 'Frosty needs additional Twitch permissions:\n\n'
'$bullets\n\n'
'${_scopeBullets(missing)}\n\n'
'Log in again to grant them.';
}
/// Copy for the explicit moderator-tools opt-in dialog.
String enableModeratorToolsDialogMessage() {
return 'Enable moderator tools to manage chat in channels you moderate:\n\n'
'${_scopeBullets(twitchModeratorScopes)}\n\n'
'You will be asked to authorize these permissions with Twitch.';
}