diff --git a/AGENTS.md b/AGENTS.md index 19a8af877..4e2962023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ symbol names don't; verify against the source, not this doc. A Flutter health app for wearables (WHOOP 4.0/5.0/MG supported today). **Fully on-device, local-first**: BLE offload → SQLite → on-device analytics → UI. No backend owns user data. Network use is limited to OTA update pointers, opt-in -telemetry/Crashlytics, and BYOK LLM calls. +telemetry/Crashlytics, and BYOK LLM calls (or the user's ChatGPT sign-in, +whose OAuth calls go to auth.openai.com). Three sibling repos, strict separation — push work to the right one: - `OpenStrap/protocol` — bytes: GATT, framing, CRC, opcodes, record decode. diff --git a/PRIVACY.md b/PRIVACY.md index 8e71cbdf5..10c5a7294 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -161,7 +161,11 @@ configure: - **AI Coach** — if you enable this feature and supply your own API key, summaries of your data are sent to the AI provider you configure (by default, OpenAI) to generate coaching responses. Off by default and - requires your own API key. + requires your own API key, or signing in with ChatGPT (Plus or Pro), in + which case the same data goes to OpenAI under your ChatGPT account and your + ChatGPT data controls, including model-training settings, may apply to it. + Check them in ChatGPT settings. The sign-in tokens are kept in the device + keychain/keystore and sent only to OpenAI. - **Health app integration** — if you enable it, the App can write derived daily metrics to Apple Health or Google Health Connect, which are controlled by your device's own OS-level health app, not by us. The App can also *read* diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dac711e32..c9b27cee1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -344,5 +344,11 @@ + + + + diff --git a/guides/AI_COACH.md b/guides/AI_COACH.md index 0cb5f1765..a88d59b9c 100644 --- a/guides/AI_COACH.md +++ b/guides/AI_COACH.md @@ -1,9 +1,11 @@ -# AI Coach — bring your own key +# AI Coach — your own model, or your ChatGPT plan The AI features in OpenStrap Edge (the Coach chat, the morning and evening briefings, and the pre-sleep journal) are all powered by a model **you** -provide. There's no OpenStrap AI service: you paste in an API key for any -OpenAI-compatible provider and the app talks to that provider directly. +provide. There's no OpenStrap AI service. There are two ways to set it up: +paste in an API key for any OpenAI-compatible provider, or +[sign in with your ChatGPT plan](#use-your-chatgpt-plan). Either way the app +talks to that provider directly. Your key is stored in the device keychain/keystore and is only ever sent to the base URL you configure — never to OpenStrap. @@ -14,7 +16,37 @@ the provider you configure. Before enabling the feature, check that provider's data retention and training policies — or point the app at a model you host yourself. -## Setup +## Use your ChatGPT plan + +If you pay for ChatGPT Plus or Pro, you can skip the API key: open +**AI Coach → settings**, tap **ChatGPT** and sign in. Eligible coach, briefing and journal requests then use +the usage included in your ChatGPT plan instead of API billing. Tap **List +models** and pick one, then **Save**. + +Before the sign-in starts, the app links OpenAI's +[Terms of Use](https://openai.com/policies/terms-of-use/) and +[Usage Policies](https://openai.com/policies/usage-policies/). They apply to +your use of ChatGPT here, so read them before you continue. + +- This uses OpenAI's official + [Sign in with ChatGPT](https://developers.openai.com/siwc/token-sharing-open-source) + for open-source apps. OpenStrap never sees your password, and never charges + for it. +- Set a limit, or disconnect OpenStrap, under + [ChatGPT settings → Usage](https://chatgpt.com/settings/usage). The + **Manage usage** button in AI settings goes there. +- When the limit is reached the coach says so and stops; it never quietly + switches to an API key. +- Your questions and the rows the coach reads go to OpenAI under your ChatGPT + account. Your ChatGPT data controls, including whether your content may be + used to train models, may apply to these requests, and they are not the same + terms as an OpenAI API key. Check them in ChatGPT settings before using this. +- **Sign out of ChatGPT** asks OpenAI to revoke the sign-in and clears it from + the phone. If the app says revocation wasn't confirmed, also disconnect + OpenStrap in ChatGPT settings. The coach then stays off until you sign in + again or pick another provider. + +## Setup with an API key diff --git a/lib/coach/coach_chatgpt.dart b/lib/coach/coach_chatgpt.dart new file mode 100644 index 000000000..2111c04e7 --- /dev/null +++ b/lib/coach/coach_chatgpt.dart @@ -0,0 +1,923 @@ +// "Sign in with ChatGPT" plan usage for the coach: the user's own ChatGPT plan +// pays for eligible requests instead of an API key. Open-source apps register +// themselves dynamically — no client secret, nothing of ours in the loop. The +// tokens live in the platform keychain beside the BYOK key and are only ever +// sent to auth.openai.com and api.openai.com. +// https://developers.openai.com/siwc/token-sharing-open-source + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:clock/clock.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../sync/reset_gate.dart'; +import 'coach_chatgpt_jwks.dart'; + +const kChatGptIssuer = 'https://auth.openai.com'; + +/// Both the OAuth `resource` and the Responses API base requests go to. +const kChatGptApiBase = 'https://api.openai.com/v1'; +const kChatGptUsageUrl = 'https://chatgpt.com/settings/usage'; +const kOpenAiTermsUrl = 'https://openai.com/policies/terms-of-use/'; +const kOpenAiUsagePoliciesUrl = 'https://openai.com/policies/usage-policies/'; +const kChatGptPlanScope = 'chatgpt.tokens.use.direct'; + +// ponytail: endpoints copied from $kChatGptIssuer/.well-known/openid-configuration +// rather than discovered per sign-in; switch to discovery if they ever move. +const _authorizeUrl = '$kChatGptIssuer/api/accounts/authorize'; +const _tokenUrl = '$kChatGptIssuer/api/accounts/oauth/token'; +const _revokeUrl = '$kChatGptIssuer/api/accounts/oauth/revoke'; +const _scopes = + 'openid profile email offline_access resource.invoke $kChatGptPlanScope'; +const _agentName = 'OpenStrap Edge'; + +/// True for the iOS/macOS errSecDuplicateItem shape the plugin surfaces when +/// its own check-then-act (containsKey -> update, else add) still lands on +/// top of an item it did not find — a leftover from a prior install is the +/// reported real-world trigger, since Keychain items routinely outlive an app +/// uninstall while everything else this app stores does not. +bool isKeychainDuplicateItemError(Object e) { + if (e is! PlatformException) return false; + final details = e.details; + if (details is int && details == -25299) return true; + final text = '${e.message ?? ''} ${e.details ?? ''}'; + return text.contains('-25299') || + text.contains('already exists in the keychain'); +} + +/// A sign-in or token failure with a message fit to show the user. +class ChatGptException implements Exception { + final String message; + ChatGptException(this.message); + @override + String toString() => message; +} + +/// The authorize URL. A null [clientId] registers this app on the account +/// (`dynamic_agent_client`); a saved one signs back in to that registration. +Uri chatGptAuthorizeUri({ + required String? clientId, + required String hostId, + required String redirectUri, + required String state, + required String nonce, + required String codeVerifier, + String? idTokenHint, +}) => + Uri.parse(_authorizeUrl).replace(queryParameters: { + 'client_id': clientId ?? 'dynamic_agent_client', + if (clientId == null) 'agent_name_hint': _agentName, + 'ext_agent_host_id': hostId, + 'id_token_hint': ?idTokenHint, + 'response_type': 'code', + 'redirect_uri': redirectUri, + 'scope': _scopes, + 'resource': kChatGptApiBase, + 'state': state, + 'nonce': nonce, + 'code_challenge_method': 'S256', + 'code_challenge': chatGptCodeChallenge(codeVerifier), + }); + +String chatGptCodeChallenge(String verifier) => + base64Url.encode(sha256.convert(ascii.encode(verifier)).bytes).replaceAll('=', ''); + +/// The ID token's `sub` and `email` once its claims check out. Only the +/// claims are checked here (iss/aud/exp/nonce); the caller verifies the +/// signature first with [ChatGptJwks.verify]. +({String sub, String email}) chatGptIdentity( + String idToken, { + required String clientId, + required String nonce, + DateTime? now, +}) { + final Map claims; + try { + final parts = idToken.split('.'); + claims = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ) as Map; + } catch (_) { + throw ChatGptException('ChatGPT returned an unreadable sign-in.'); + } + final aud = claims['aud']; + final exp = claims['exp']; + final ok = claims['iss'] == kChatGptIssuer && + (aud == clientId || (aud is List && aud.contains(clientId))) && + claims['nonce'] == nonce && + exp is num && + exp * 1000 > (now ?? clock.now()).millisecondsSinceEpoch && + claims['sub'] is String; + if (!ok) throw ChatGptException('ChatGPT returned a sign-in that did not check out.'); + return (sub: claims['sub'] as String, email: (claims['email'] ?? '').toString()); +} + +/// The final `response` of a streamed Responses call. This route only +/// streams; the whole body is buffered and the completed response is handed +/// to the same parser as non-streamed replies. `response.completed` is the +/// success signal, but on this route its `output` can be empty — the items +/// arrive earlier as `response.output_item.done`, so they are taken from +/// there. Anything short of `response.completed` is a failure. +Map chatGptCompletedResponse(String sse) { + final items = <(int, Map)>[]; + for (final line in const LineSplitter().convert(sse)) { + if (!line.startsWith('data:')) continue; + final Object? event; + try { + event = jsonDecode(line.substring(5).trim()); + } catch (_) { + continue; // `[DONE]` and keep-alives + } + if (event is! Map) continue; + switch (event['type']) { + case 'response.output_item.done': + final item = event['item']; + if (item is Map) { + final i = event['output_index']; + items.add((i is int ? i : items.length, item.cast())); + } + case 'response.completed': + final r = event['response']; + if (r is! Map) break; + final out = r['output']; + if (out is List && out.isNotEmpty) return r.cast(); + items.sort((a, b) => a.$1.compareTo(b.$1)); + return {...r.cast(), 'output': [for (final e in items) e.$2]}; + case 'response.failed' || 'error': + final err = (event['response'] is Map ? event['response']['error'] : null) ?? + event['error'] ?? + event; + throw ChatGptException(chatGptErrorMessage( + null, + err is Map ? err['code']?.toString() : null, + err is Map ? err['message']?.toString() : null, + )); + case 'response.incomplete': + throw ChatGptException('ChatGPT stopped before finishing the reply.'); + } + } + throw ChatGptException('The ChatGPT reply ended early. Try again.'); +} + +/// A failed plan-usage request, worded for the user. Billing errors stop the +/// request — they never fall back to another way of paying. +String chatGptErrorMessage(int? status, String? code, String? detail) { + switch (code) { + case 'subscription_sharing_usage_limit_exceeded': + return 'Usage limit reached. Review your plan or this app’s limit in ' + 'ChatGPT settings: $kChatGptUsageUrl'; + case 'subscription_sharing_user_not_eligible': + return 'This ChatGPT account can’t use its plan in other apps.'; + case 'subscription_sharing_usage_unavailable' || + 'subscription_sharing_user_unavailable': + return 'ChatGPT plan usage is unavailable right now. Try again later.'; + case 'subscription_sharing_unsupported_capability': + return 'ChatGPT plan usage doesn’t support part of this request' + '${detail == null ? '' : ': $detail'}'; + case 'subscription_sharing_invalid_user': + return 'ChatGPT didn’t accept this sign-in. If you disconnected ' + 'OpenStrap in ChatGPT settings, sign in again.'; + } + if (status == 401) { + return 'ChatGPT didn’t accept this sign-in. Sign in again from AI settings.'; + } + if (status == 503) { + return 'ChatGPT plan usage is unavailable right now. Try again later.'; + } + final what = detail ?? code ?? 'unknown error'; + return status == null ? 'ChatGPT error: $what' : 'ChatGPT error ($status): $what'; +} + +/// [chatGptErrorMessage] for a non-200 body, which is either a Responses +/// `{"error": {...}}` or an admission `{"detail": "..."}`. +String chatGptHttpError(int status, String body) { + try { + final j = jsonDecode(body); + final e = j is Map ? j['error'] : null; + if (e is Map) { + return chatGptErrorMessage( + status, e['code']?.toString(), (e['param'] ?? e['message'])?.toString()); + } + if (j is Map && j['detail'] != null) { + return chatGptErrorMessage(status, null, j['detail'].toString()); + } + } catch (_) {} + return chatGptErrorMessage( + status, null, body.length > 200 ? body.substring(0, 200) : body); +} + +/// The namespace the coach's function tools are grouped under on the +/// ChatGPT-plan route. +const kChatGptToolNamespace = 'openstrap'; + +/// The Responses body this route accepts, from the coach's normal one: it +/// must stream, and rejects `system` input items (developer is the same thing +/// here). Sampling params are already never sent on Responses. +/// +/// Function tools are only accepted here grouped in a namespace (or through +/// `additional_tools` input items), not as bare top-level tools, so they go +/// under [kChatGptToolNamespace]. The model's calls then carry that +/// namespace beside the bare function name, and a `function_call` replayed +/// from history without one gets it too, so it matches a declared tool. +/// https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations +Map chatGptRequest(Map body) { + final tools = body['tools']; + final functions = >[]; + final others = []; + if (tools is List) { + for (final t in tools) { + if (t is Map && t['type'] == 'function') { + functions.add(t.cast()); + } else { + others.add(t); + } + } + } + final names = {for (final f in functions) f['name']}; + return { + ...body, + 'stream': true, + 'store': false, + 'input': [ + for (final item in body['input'] as List) + if (item is Map && item['role'] == 'system') + {...item, 'role': 'developer'} + else if (item is Map && + item['type'] == 'function_call' && + item['namespace'] == null && + names.contains(item['name'])) + {...item, 'namespace': kChatGptToolNamespace} + else + item, + ], + if (tools is List) + 'tools': [ + if (functions.isNotEmpty) + { + 'type': 'namespace', + 'name': kChatGptToolNamespace, + 'description': 'Read and present this person\'s OpenStrap health data.', + 'tools': functions, + }, + ...others, + ], + }; +} + +class ChatGptSession { + final String sub, email, idToken, accessToken, refreshToken, scope; + final DateTime expiresAt; + + const ChatGptSession({ + required this.sub, + required this.email, + required this.idToken, + required this.accessToken, + required this.refreshToken, + required this.scope, + required this.expiresAt, + }); + + /// Signed in is not enough: the user can sign in and decline plan usage. + bool get sharing => scope.split(' ').contains(kChatGptPlanScope); + + Map toJson() => { + 'sub': sub, + 'email': email, + 'id_token': idToken, + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'scope': scope, + 'expires_at': expiresAt.millisecondsSinceEpoch, + }; + + static ChatGptSession fromJson(Map j) => ChatGptSession( + sub: j['sub'] as String, + email: j['email'] as String, + idToken: j['id_token'] as String, + accessToken: j['access_token'] as String, + refreshToken: j['refresh_token'] as String, + scope: j['scope'] as String, + expiresAt: DateTime.fromMillisecondsSinceEpoch(j['expires_at'] as int), + ); +} + +/// One ChatGPT account per install. The issued client id and host id are kept +/// across sign-outs — OpenAI wants a returning sign-in to reuse both. +class ChatGptAuth extends ChangeNotifier { + ChatGptAuth({http.Client? client}) : _client = client; + + static const _kClientId = 'coach_chatgpt_client_id'; + static const _kHostId = 'coach_chatgpt_host_id'; + static const _kPresent = 'coach_chatgpt_present'; + static const _kWelcomed = 'coach_chatgpt_welcomed'; + static const _kSession = 'coach_chatgpt_session'; // secure storage + + // Same first-unlock reasoning as CoachConfig's key: background relaunches + // happen while the phone is locked. + static const _apple = IOSOptions(accessibility: KeychainAccessibility.first_unlock); + static const _macos = MacOsOptions(accessibility: KeychainAccessibility.first_unlock); + final FlutterSecureStorage _secure = const FlutterSecureStorage(); + final http.Client? _client; + + /// OpenAI's signing keys, cached for the life of this object. + late final ChatGptJwks _jwks = ChatGptJwks( + issuer: kChatGptIssuer, + get: (u) => _http((c) => c.get(u).timeout(const Duration(seconds: 20))), + ); + + ChatGptSession? _session; + bool _unreadable = false; + HttpServer? _pending; + + /// A code exchange that is out: completes with whether every token it was + /// issued is either stored or confirmed revoked. [forgetAll] waits on it so + /// a reset cannot report a clean wipe while a late exchange still holds + /// tokens it failed to revoke. + Future? _exchanging; + Future? _refreshing; + + /// The [_generation] [_refreshing] was started under. A session that came + /// after it never shares it: that refresh is spending another session's + /// token. + int _refreshingGeneration = -1; + + /// Bumped by every [signIn] and every [cancelSignIn]. A code exchange that + /// finishes under an older value was cancelled (or replaced) while it was + /// out, so its tokens are revoked rather than stored. + int _attempt = 0; + + /// Bumped whenever a sign-in commits a session, and at the very start of + /// [signOut]/[forgetAll]. A [load], [_refresh] or [signIn] that started under + /// an older value must not apply its result — a slow keychain read or token call + /// would otherwise bring back a session the user just ended (same idea as + /// CoachConfig._generation). + int _generation = 0; + + /// Keychain writes, deletes and the marker run one at a time, like + /// CoachConfig._serialized: a sign-out can then never interleave with a + /// store's awaits — it runs after one already writing, and ahead of any + /// still queued (which see the new generation and are rejected). + Future _lock = Future.value(); + + Future _serialized(Future Function() op) { + final done = _lock.then((_) => op()); + // A failed operation must not wedge the queue. + _lock = done.catchError((_) {}); + return done; + } + + ChatGptSession? get session => _session; + bool get signedIn => _session != null; + bool get sharing => _session?.sharing ?? false; + + /// Signed in, but the keychain could not be read (locked phone). Fixes + /// itself on the next foreground [load]. + bool get unreadable => _unreadable; + + Future _http(Future Function(http.Client) f) async { + final c = _client ?? http.Client(); + try { + return await f(c); + } finally { + if (_client == null) c.close(); + } + } + + Future load({bool trusted = false}) async { + final generation = _generation; + final prefs = await SharedPreferences.getInstance(); + final present = prefs.getBool(_kPresent) ?? false; + try { + final raw = await _secure.read(key: _kSession, iOptions: _apple, mOptions: _macos); + if (generation != _generation) return; + if (raw != null && raw.isNotEmpty && !present) { + // Fails closed: every store writes the marker, so a record without it + // is one a sign-out could not delete (or a reinstall's leftover). + await _serialized(() async { + if (generation != _generation) return; // a sign-in just committed + try { + await _secure.delete(key: _kSession, iOptions: _apple, mOptions: _macos); + } catch (_) {} + }); + if (generation != _generation) return; + _session = null; + _unreadable = false; + } else if (raw != null && raw.isNotEmpty) { + _session = ChatGptSession.fromJson(jsonDecode(raw) as Map); + _unreadable = false; + } else if (trusted || !present) { + _session = null; + _unreadable = false; + if (present) await prefs.setBool(_kPresent, false); + } else { + _unreadable = true; + } + } catch (_) { + if (generation != _generation) return; + _unreadable = present && _session == null; + } + notifyListeners(); + } + + /// Every caller passes the [generation] it started under, checked INSIDE the + /// lock before any write: if the session was signed out or replaced since, + /// the result is dropped. A sign-in or [debugSetSession] passes `commit` to + /// start a new generation once its write lands. + Future _store(ChatGptSession? s, + {required int generation, bool commit = false, int? attempt}) => + _serialized(() async { + void current() { + if (generation != _generation) throw ChatGptException('Signed out.'); + if (attempt != null && attempt != _attempt) { + throw ChatGptException('Sign-in was cancelled.'); + } + // A sign-in that lands during a reset is revoked, not stored (see [signIn]). + if (commit && ResetGate.active) throw ChatGptException('Signed out.'); + } + + current(); + final prefs = await SharedPreferences.getInstance(); + if (s == null) { + // Best effort: the marker below is what [load] trusts, and the spent + // session must leave memory even if the keychain item lingers. + try { + await _secure.delete(key: _kSession, iOptions: _apple, mOptions: _macos); + } catch (_) {} + } else { + final raw = jsonEncode(s.toJson()); + try { + await _secure.write(key: _kSession, value: raw, iOptions: _apple, mOptions: _macos); + } on Object catch (e) { + // Only a leftover item from a previous install (see CoachConfig.save) + // is worth deleting; any other failure leaves the stored session alone. + if (!isKeychainDuplicateItemError(e)) rethrow; + await _secure.delete(key: _kSession, iOptions: _apple, mOptions: _macos); + await _secure.write(key: _kSession, value: raw, iOptions: _apple, mOptions: _macos); + } + } + await prefs.setBool(_kPresent, s != null); + // A sign-out that began mid-write clears the keychain right after us. + current(); + if (commit) _generation++; + _session = s; + _unreadable = false; + notifyListeners(); + }); + + static String _random() { + final r = Random.secure(); + return base64Url.encode(List.generate(32, (_) => r.nextInt(256))).replaceAll('=', ''); + } + + /// Runs the browser sign-in. [open] shows the authorize page and must keep + /// this app alive while it is up (an in-app browser), because the redirect + /// lands on a listener on this device's loopback. + Future signIn(Future Function(Uri) open) async { + // A reset does not block the UI, and it clears every preference after + // [forgetAll]: a sign-in it overlapped would be left half-stored. + if (ResetGate.active) { + throw ChatGptException('Wait for the reset to finish, then sign in again.'); + } + final generation = _generation; + final attempt = ++_attempt; + final prefs = await SharedPreferences.getInstance(); + final hostId = prefs.getString(_kHostId) ?? 'urn:uuid:${const Uuid().v4()}'; + await prefs.setString(_kHostId, hostId); + final savedClientId = prefs.getString(_kClientId); + final server = _pending = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + Completer? exchanged; + try { + // Cancelled while the listener was binding: [cancelSignIn] had no + // server to close yet, so this attempt would open the browser anyway. + if (attempt != _attempt) throw ChatGptException('Sign-in was cancelled.'); + final redirectUri = 'http://127.0.0.1:${server.port}/auth/callback'; + final state = _random(), nonce = _random(), verifier = _random(); + final uri = chatGptAuthorizeUri( + clientId: savedClientId, + hostId: hostId, + redirectUri: redirectUri, + state: state, + nonce: nonce, + codeVerifier: verifier, + idTokenHint: _session?.idToken, + ); + // Listening BEFORE the browser opens: the redirect can beat [open]'s + // own return. ignore() only keeps a failed open from leaving this + // future's error unhandled; it is still awaited below. + final callback = _callback(server, state)..ignore(); + if (!await open(uri)) throw ChatGptException('Could not open the browser.'); + final q = await callback.timeout( + const Duration(minutes: 10), + onTimeout: () => throw ChatGptException('Sign-in timed out. Try again.'), + ); + if (q['error'] != null) { + throw ChatGptException(q['error'] == 'access_denied' + ? 'Sign-in was cancelled.' + : 'ChatGPT sign-in failed: ${q['error_description'] ?? q['error']}'); + } + final issued = q['client_id']; + if (savedClientId != null && issued != null && issued != savedClientId) { + throw ChatGptException('ChatGPT returned a different app registration.'); + } + final clientId = savedClientId ?? issued; + if (clientId == null || q['code'] == null) { + throw ChatGptException('ChatGPT sign-in returned no code.'); + } + // Registered before the first await from here on, so a reset that + // starts while the client id is being written still waits for this + // exchange (and deletes the client id only after it). + exchanged = Completer(); + _exchanging = exchanged.future; + // Saved BEFORE the exchange, so a retry after a failed one reuses it. + await prefs.setString(_kClientId, clientId); + if (generation != _generation) throw ChatGptException('Signed out.'); + if (attempt != _attempt) throw ChatGptException('Sign-in was cancelled.'); + final Map t; + try { + t = await _token({ + 'grant_type': 'authorization_code', + 'client_id': clientId, + 'code': q['code']!, + 'code_verifier': verifier, + 'redirect_uri': redirectUri, + }); + } on _TokenError catch (e) { + throw ChatGptException( + 'ChatGPT sign-in failed (${e.code ?? e.status}). Try again.'); + } + // From here on tokens exist on OpenAI's side. Any way this attempt ends + // other than a stored session — a sign-out, reset or cancel that landed + // while the exchange was out, or a token that does not check out — + // revokes them, with the client id captured above (a reset deletes the + // saved one). + try { + if (generation != _generation) throw ChatGptException('Signed out.'); + if (attempt != _attempt) throw ChatGptException('Sign-in was cancelled.'); + final idToken = t['id_token'] as String?; + if (idToken == null) throw ChatGptException('ChatGPT returned no identity.'); + try { + await _jwks.verify(idToken); + } on ChatGptSignatureException catch (e) { + debugPrint('[chatgpt] $e'); + throw ChatGptException('ChatGPT returned a sign-in that did not check out.'); + } + final who = chatGptIdentity(idToken, clientId: clientId, nonce: nonce); + await _store( + _sessionFrom(t, sub: who.sub, email: who.email, idToken: idToken), + generation: generation, + commit: true, + attempt: attempt, + ); + } catch (_) { + exchanged.complete(await _revokeIssued( + refreshToken: t['refresh_token'] is String ? t['refresh_token'] as String : null, + accessToken: t['access_token'] as String, // [_token] checked it + clientId: clientId, + )); + rethrow; + } + } finally { + // Stored, or no tokens were issued: nothing is left behind. + if (exchanged != null && !exchanged.isCompleted) exchanged.complete(true); + if (exchanged != null && identical(_exchanging, exchanged.future)) _exchanging = null; + if (identical(_pending, server)) _pending = null; + await server.close(force: true); + } + } + + /// Ends a [signIn] still waiting on the browser. Nothing tells the app when + /// the user simply closes the in-app browser, so this is the way out. + void cancelSignIn() { + _attempt++; + _pending?.close(force: true); + } + + /// The first callback carrying our [state]. Anything else that reaches the + /// loopback port is turned away without ending the sign-in. + static Future> _callback(HttpServer server, String state) async { + await for (final req in server) { + final q = req.uri.queryParameters; + final ours = req.uri.path == '/auth/callback' && q['state'] == state; + req.response + ..statusCode = ours ? 200 : 400 + ..headers.contentType = ContentType.html + ..write(ours + ? '' + '

Done. You can close ' + 'this page and go back to OpenStrap.

' + : 'Bad request'); + await req.response.close(); + if (ours) return q; + } + throw ChatGptException('Sign-in was cancelled.'); + } + + ChatGptSession _sessionFrom(Map t, + {required String sub, required String email, required String idToken}) => + ChatGptSession( + sub: sub, + email: email, + idToken: idToken, + accessToken: t['access_token'] as String, + refreshToken: t['refresh_token'] as String, + scope: (t['scope'] ?? '').toString(), + expiresAt: clock.now().add(Duration(seconds: (t['expires_in'] as num?)?.toInt() ?? 3600)), + ); + + Future> _token(Map form) async { + final resp = await _http((c) => c + .post(Uri.parse(_tokenUrl), body: {...form, 'resource': kChatGptApiBase}) + .timeout(const Duration(seconds: 30))); + Object? j; + try { + j = jsonDecode(resp.body); + } catch (_) {} + if (resp.statusCode == 200 && j is Map && j['access_token'] is String) { + return j.cast(); + } + final err = j is Map ? j['error']?.toString() : null; + throw _TokenError(resp.statusCode, err); + } + + /// A current access token, refreshed when it is within five minutes of + /// expiry. Concurrent callers share one refresh — the refresh token rotates, + /// and a second refresh with the old one would be `refresh_token_reused`. + Future accessToken() { + final s = _session; + if (s == null) { + return Future.error(ChatGptException(_unreadable + ? 'Your ChatGPT sign-in could not be read while the phone was locked. Open the app and try again.' + : 'Sign in with ChatGPT first.')); + } + if (s.expiresAt.isAfter(clock.now().add(const Duration(minutes: 5)))) { + return Future.value(s.accessToken); + } + final pending = _refreshing; + if (pending != null && _refreshingGeneration == _generation) return pending; + _refreshingGeneration = _generation; + late final Future f; + f = _refresh(s).whenComplete(() { + if (identical(_refreshing, f)) _refreshing = null; + }); + return _refreshing = f; + } + + Future _refresh(ChatGptSession s) async { + final generation = _generation; + final prefs = await SharedPreferences.getInstance(); + final clientId = prefs.getString(_kClientId); + if (clientId == null) throw ChatGptException('Sign in with ChatGPT again.'); + final Map t; + try { + t = await _token({ + 'grant_type': 'refresh_token', + 'client_id': clientId, + 'refresh_token': s.refreshToken, + }); + } on _TokenError catch (e) { + if (!e.terminal) { + throw ChatGptException('Could not renew the ChatGPT sign-in (${e.status}). Try again.'); + } + // Best effort: a failed clear (or a sign-out that got there first) must + // not replace the message saying why the coach stopped. + try { + await _store(null, generation: generation); + } catch (_) {} + throw ChatGptException('Your ChatGPT sign-in has ended. Sign in again from AI settings.'); + } catch (e) { + // A network hiccup is not a revoked session — the tokens stay. + throw ChatGptException('Could not reach ChatGPT to renew the sign-in: $e'); + } + final next = _sessionFrom( + {'refresh_token': s.refreshToken, 'scope': s.scope, ...t}, + sub: s.sub, + email: s.email, + idToken: (t['id_token'] as String?) ?? s.idToken, + ); + if (generation != _generation) { + // Signed out (or replaced) while the call was out: the rotated tokens + // belong to a session that has ended, so they are revoked here rather + // than handed on — a later sign-out is about a later session. + throw _StaleRefresh(await _revokeIssued( + refreshToken: next.refreshToken, accessToken: next.accessToken, clientId: clientId)); + } + try { + await _store(next, generation: generation); + } catch (_) { + if (generation != _generation) { + // Signed out while it was writing: same as above. + throw _StaleRefresh(await _revokeIssued( + refreshToken: next.refreshToken, accessToken: next.accessToken, clientId: clientId)); + } + // The keychain write failed, but the old refresh token is already spent: + // keep the rotated session in memory so the next refresh does not reuse it. + _session = next; + } + return next.accessToken; + } + + /// Revokes the refresh and access tokens and forgets the session. Returns false when + /// revocation could not be confirmed; the tokens are cleared regardless, and + /// the user can still disconnect the app in ChatGPT settings. + Future signOut() async { + final generation = ++_generation; // before any await: see [_generation] + cancelSignIn(); + final s = _session; + final refreshing = _refreshing; + // Memory first, so nothing keeps using the session while revocation is out. + _session = null; + _unreadable = false; + notifyListeners(); + // An in-flight refresh revokes what it rotated to itself; the token it + // spent is then dead and there is nothing left to revoke here. + final rotated = await _rotationOf(refreshing); + final prefs = await SharedPreferences.getInstance(); + final clientId = prefs.getString(_kClientId); + final token = s?.refreshToken; + final revoked = rotated ?? + (s == null || + (clientId != null && + await _revokeIssued( + refreshToken: token, accessToken: s.accessToken, clientId: clientId))); + // Signed out either way. If the delete fails the item may linger, but the + // marker is cleared and [load] fails closed on a record without it, so it + // is never restored (it is deleted then); the credential is not guaranteed + // gone until that happens. + await _serialized(() async { + // The button to sign in again is back while revocation is out: a sign-in + // that committed since (or a later sign-out) owns the keychain now. + if (generation != _generation) return; + try { + await _secure.delete(key: _kSession, iOptions: _apple, mOptions: _macos); + } catch (_) {} + await prefs.setBool(_kPresent, false); + }); + return revoked; + } + + /// Waits out a refresh that was in flight when the session ended. Non-null + /// when it had rotated the tokens (and so revoked them itself): whether that + /// revocation was confirmed. + static Future _rotationOf(Future? refreshing) async { + if (refreshing == null) return null; + try { + await refreshing; + } on _StaleRefresh catch (e) { + return e.revoked; + } catch (_) {} + return null; + } + + /// True when OpenAI confirmed [token] is revoked. + Future _revoke(String token, String clientId, + {String hint = 'refresh_token'}) async { + try { + final r = await _http((c) => c.post(Uri.parse(_revokeUrl), body: { + 'token': token, + 'token_type_hint': hint, + 'client_id': clientId, + }).timeout(const Duration(seconds: 15))); + return r.statusCode == 200; + } catch (_) { + return false; + } + } + + /// Revokes tokens this app holds but will not keep: the refresh token, which + /// is what keeps the grant alive, and the access token so it does not stay + /// usable for the rest of its hour. True only when both were confirmed. + Future _revokeIssued( + {required String? refreshToken, + required String? accessToken, + required String clientId}) async { + // Both at once, so a dead network costs one request timeout, not two: + // sign-out and "Reset all data" wait on this before clearing local state. + final results = await Future.wait([ + if (refreshToken != null) _revoke(refreshToken, clientId), + if (accessToken != null) _revoke(accessToken, clientId, hint: 'access_token'), + ]); + return results.every((ok) => ok); + } + + /// "Reset all data": the session plus the registration and welcome flag. + /// The session is revoked first, best effort, while the registration to + /// revoke it with is still here. + /// Returns false when a session may outlive the reset: revocation was not + /// confirmed, or the keychain item could not be deleted. + Future forgetAll() async { + _generation++; + cancelSignIn(); + final exchanging = _exchanging; + var s = _session; + final refreshing = _refreshing; + _session = null; + _unreadable = false; + // As in [signOut]: an in-flight refresh revokes its own rotated tokens. + final rotated = await _rotationOf(refreshing); + final prefs = await SharedPreferences.getInstance(); + final clientId = prefs.getString(_kClientId); + // [load] runs in the background at startup, so nothing in memory need not + // mean nothing stored: read the keychain before deleting it, and do not + // call a session revoked when it could not be read. + var unreadable = false; + if (s == null && rotated == null) { + try { + final raw = await _secure.read(key: _kSession, iOptions: _apple, mOptions: _macos); + if (raw != null && raw.isNotEmpty) { + s = ChatGptSession.fromJson(jsonDecode(raw) as Map); + } + } catch (_) { + unreadable = prefs.getBool(_kPresent) ?? false; + } + } + final token = s?.refreshToken; + final revoked = !unreadable && + (rotated ?? + (s == null || + (clientId != null && + await _revokeIssued( + refreshToken: token, accessToken: s.accessToken, clientId: clientId)))); + // A sign-in whose code exchange was out revokes what it was issued itself. + final exchangeClean = exchanging == null || await exchanging; + var deleted = true; + await _serialized(() async { + try { + await _secure.delete(key: _kSession, iOptions: _apple, mOptions: _macos); + } catch (_) { + deleted = false; + } + final prefs = await SharedPreferences.getInstance(); + for (final k in [_kClientId, _kHostId, _kPresent, _kWelcomed]) { + await prefs.remove(k); + } + }); + notifyListeners(); + return revoked && exchangeClean && deleted; + } + + /// True exactly once, on the first sign-in that has plan usage. + Future takeWelcome() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_kWelcomed) ?? false) return false; + await prefs.setBool(_kWelcomed, true); + return true; + } + + /// Model slugs this account may use, in the server's order. + Future> listModels() async { + final token = await accessToken(); + final resp = await _http((c) => c.get( + Uri.parse('$kChatGptApiBase/models'), + headers: {'Authorization': 'Bearer $token'}, + ).timeout(const Duration(seconds: 20))); + if (resp.statusCode != 200) { + throw ChatGptException(chatGptHttpError(resp.statusCode, resp.body)); + } + final j = jsonDecode(resp.body); + final models = j is Map ? j['models'] : null; + return [ + if (models is List) + for (final m in models) + if (m is Map && m['visibility'] == 'list' && m['slug'] is String) + m['slug'] as String, + ]; + } + + @visibleForTesting + Future debugSetSession(ChatGptSession? s) => + _store(s, generation: _generation, commit: true); +} + +/// A refresh that came back after its session ended. Its rotated tokens were +/// revoked on the spot; [revoked] says whether OpenAI confirmed it. +class _StaleRefresh extends ChatGptException { + final bool revoked; + _StaleRefresh(this.revoked) : super('Signed out.'); +} + +class _TokenError implements Exception { + final int status; + final String? code; + _TokenError(this.status, this.code); + + /// The refresh token itself is dead, as opposed to a transient failure. + bool get terminal => + const { + 'invalid_grant', + 'invalid_refresh_token', + 'token_expired', + 'refresh_token_expired', + 'refresh_token_invalidated', + 'refresh_token_reused', + }.contains(code); +} diff --git a/lib/coach/coach_chatgpt_jwks.dart b/lib/coach/coach_chatgpt_jwks.dart new file mode 100644 index 000000000..af18a94c8 --- /dev/null +++ b/lib/coach/coach_chatgpt_jwks.dart @@ -0,0 +1,178 @@ +// Signature check for the ChatGPT ID token: RS256 against the keys OpenAI +// publishes at the discovery document's `jwks_uri`. Everything here fails +// closed — a token is trusted only when its header, key and signature all +// check out, and any fetch or parse problem is a failed sign-in, never a skip. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:clock/clock.dart'; +import 'package:http/http.dart' as http; +import 'package:pointycastle/export.dart'; + +/// Raised for any ID token whose signature could not be verified. +class ChatGptSignatureException implements Exception { + final String reason; + ChatGptSignatureException(this.reason); + @override + String toString() => 'ID token signature not verified: $reason'; +} + +/// One RSA signing key from a JWKS. +class ChatGptJwk { + final String kid; + final RSAPublicKey key; + const ChatGptJwk(this.kid, this.key); +} + +BigInt _uint(String b64) { + final bytes = base64Url.decode(base64Url.normalize(b64)); + var v = BigInt.zero; + for (final b in bytes) { + v = (v << 8) | BigInt.from(b); + } + return v; +} + +/// The usable RS256 signing keys in a JWKS document. Keys for another +/// algorithm, for encryption, or shorter than 2048 bits are left out. +Map parseChatGptJwks(String body) { + final Object? j; + try { + j = jsonDecode(body); + } catch (_) { + throw ChatGptSignatureException('unreadable key set'); + } + final keys = j is Map ? j['keys'] : null; + if (keys is! List) throw ChatGptSignatureException('unreadable key set'); + final out = {}; + for (final k in keys) { + if (k is! Map) continue; + final kid = k['kid'], n = k['n'], e = k['e']; + if (k['kty'] != 'RSA' || kid is! String || n is! String || e is! String) continue; + if (k['alg'] != null && k['alg'] != 'RS256') continue; + if (k['use'] != null && k['use'] != 'sig') continue; + try { + final modulus = _uint(n); + if (modulus.bitLength < 2048) continue; + out[kid] = ChatGptJwk(kid, RSAPublicKey(modulus, _uint(e))); + } catch (_) { + continue; + } + } + return out; +} + +/// The `kid` of an RS256 JWT, after checking the header asks for exactly +/// that algorithm (no `none`, no HMAC, no key embedded in the token). +String chatGptIdTokenKid(String jwt) { + final parts = jwt.split('.'); + if (parts.length != 3) throw ChatGptSignatureException('not a signed JWT'); + final Object? header; + try { + header = jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(parts[0])))); + } catch (_) { + throw ChatGptSignatureException('unreadable header'); + } + if (header is! Map) throw ChatGptSignatureException('unreadable header'); + if (header['alg'] != 'RS256') { + throw ChatGptSignatureException('unexpected alg ${header['alg']}'); + } + if (header.containsKey('jwk') || header.containsKey('jku') || header.containsKey('x5u')) { + throw ChatGptSignatureException('token names its own key'); + } + if (header['crit'] != null) throw ChatGptSignatureException('unsupported crit header'); + final kid = header['kid']; + if (kid is! String || kid.isEmpty) throw ChatGptSignatureException('no kid'); + return kid; +} + +/// True when [jwt]'s RS256 signature verifies under [key]. +bool chatGptVerifyRs256(String jwt, RSAPublicKey key) { + final parts = jwt.split('.'); + if (parts.length != 3) return false; + final Uint8List sig; + try { + sig = Uint8List.fromList(base64Url.decode(base64Url.normalize(parts[2]))); + } catch (_) { + return false; + } + final signer = RSASigner(SHA256Digest(), '0609608648016503040201') + ..init(false, PublicKeyParameter(key)); + try { + return signer.verifySignature( + Uint8List.fromList(ascii.encode('${parts[0]}.${parts[1]}')), + RSASignature(sig), + ); + } catch (_) { + return false; + } +} + +/// OpenAI's published signing keys, fetched from the discovery document's +/// `jwks_uri` and cached for the life of the object. A token signed with a +/// `kid` not in the cache triggers one refetch (key rotation); a `kid` still +/// missing after that is rejected. +class ChatGptJwks { + ChatGptJwks({required this.issuer, required this.get}); + + final String issuer; + + /// Runs one request with the caller's client. + final Future Function(Uri) get; + + Map? _keys; + DateTime? _fetchedAt; + + /// Refetching on every unknown kid would let a stream of forged tokens turn + /// into a stream of requests; one sign-in only ever needs one refetch. + static const _minRefetch = Duration(seconds: 30); + + Future> _fetch() async { + final discovery = await get(Uri.parse('$issuer/.well-known/openid-configuration')); + if (discovery.statusCode != 200) { + throw ChatGptSignatureException('discovery returned ${discovery.statusCode}'); + } + final Object? d; + try { + d = jsonDecode(discovery.body); + } catch (_) { + throw ChatGptSignatureException('unreadable discovery document'); + } + if (d is! Map || d['issuer'] != issuer || d['jwks_uri'] is! String) { + throw ChatGptSignatureException('unexpected discovery document'); + } + final jwksUri = Uri.tryParse(d['jwks_uri'] as String); + if (jwksUri == null || jwksUri.scheme != 'https' || jwksUri.host.isEmpty) { + throw ChatGptSignatureException('jwks_uri is not https'); + } + final r = await get(jwksUri); + if (r.statusCode != 200) throw ChatGptSignatureException('jwks returned ${r.statusCode}'); + final keys = parseChatGptJwks(r.body); + _keys = keys; + _fetchedAt = clock.now(); + return keys; + } + + /// Throws [ChatGptSignatureException] unless [jwt] is RS256-signed by one of + /// the issuer's current keys. + Future verify(String jwt) async { + final kid = chatGptIdTokenKid(jwt); + var keys = _keys; + if (keys == null || !keys.containsKey(kid)) { + final last = _fetchedAt; + if (keys == null || last == null || clock.now().difference(last) >= _minRefetch) { + try { + keys = await _fetch(); + } on ChatGptSignatureException { + rethrow; + } catch (e) { + throw ChatGptSignatureException('could not fetch keys: $e'); + } + } + } + final jwk = keys[kid]; + if (jwk == null) throw ChatGptSignatureException('unknown kid'); + if (!chatGptVerifyRs256(jwt, jwk.key)) throw ChatGptSignatureException('bad signature'); + } +} diff --git a/lib/coach/coach_config.dart b/lib/coach/coach_config.dart index df5986dd1..a96da09db 100644 --- a/lib/coach/coach_config.dart +++ b/lib/coach/coach_config.dart @@ -3,11 +3,14 @@ // SharedPreferences. NOTHING here ever touches our backend — the key stays on the // device and the app calls the OpenAI-compatible provider directly. +import 'dart:async'; + import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'coach_chatgpt.dart'; + /// The origin (`scheme://host[:port]`) [url] resolves to, or the trimmed /// string itself when it doesn't parse as a URI with a host. /// @@ -119,26 +122,13 @@ bool isPrivateCoachHost(String url) { } } -/// True for the iOS/macOS errSecDuplicateItem shape the plugin surfaces when -/// its own check-then-act (containsKey -> update, else add) still lands on -/// top of an item it did not find — a leftover from a prior install is the -/// reported real-world trigger, since Keychain items routinely outlive an app -/// uninstall while everything else this app stores does not. -bool _isDuplicateItemError(Object e) { - if (e is! PlatformException) return false; - final details = e.details; - if (details is int && details == -25299) return true; - final text = '${e.message ?? ''} ${e.details ?? ''}'; - return text.contains('-25299') || - text.contains('already exists in the keychain'); -} - class CoachConfig extends ChangeNotifier { static const _kBaseUrl = 'coach_base_url'; static const _kModel = 'coach_model'; static const _kApi = 'coach_api'; static const _kKey = 'coach_api_key'; // secure storage static const _kTimeoutSeconds = 'coach_timeout_seconds'; + static const _kUseChatGpt = 'coach_use_chatgpt'; /// A local model's first response includes Ollama/LM Studio loading the /// whole model into memory, which alone can pass two minutes on modest @@ -189,6 +179,17 @@ class CoachConfig extends ChangeNotifier { final FlutterSecureStorage _secure = const FlutterSecureStorage(); + CoachConfig({ChatGptAuth? chatgpt}) : chatgpt = chatgpt ?? ChatGptAuth() { + this.chatgpt.addListener(notifyListeners); + } + + /// "Sign in with ChatGPT". When [usesChatGpt], requests go to OpenAI's + /// Responses API on the user's ChatGPT plan instead of the BYOK endpoint; + /// the BYOK settings are kept untouched for switching back. + final ChatGptAuth chatgpt; + bool _useChatGpt = false; + bool get usesChatGpt => _useChatGpt; + String _baseUrl = defaultBaseUrl; CoachApi _api = CoachApi.defaultFor(defaultBaseUrl); String _model = ''; @@ -200,7 +201,7 @@ class CoachConfig extends ChangeNotifier { /// user can fix by pasting one — this one fixes itself on the next unlocked /// read, and telling them to set a key up again would be wrong. bool _keyUnreadable = false; - bool get keyUnreadable => _keyUnreadable; + bool get keyUnreadable => _useChatGpt ? chatgpt.unreadable : _keyUnreadable; /// True while it is still unknown whether a key is stored — an install that /// predates the marker, read while the keychain was unavailable. Not shown to @@ -255,7 +256,7 @@ class CoachConfig extends ChangeNotifier { } String get baseUrl => _baseUrl; - CoachApi get api => _api; + CoachApi get api => _useChatGpt ? CoachApi.responses : _api; String get model => _model; /// The saved, user-configurable timeout — meaningful only for a local /// endpoint (see [requestTimeout]), but kept intact and readable regardless @@ -284,11 +285,18 @@ class CoachConfig extends ChangeNotifier { /// Narrower than [isLocalEndpoint]; see [isPrivateCoachHost]. bool get isPrivateEndpoint => isPrivateCoachHost(apiBase); - bool get configured => - (hasKey || isLocalEndpoint) && _baseUrl.isNotEmpty && _model.isNotEmpty; + bool get configured => _useChatGpt + ? chatgpt.sharing && _model.isNotEmpty + : (hasKey || isLocalEndpoint) && _baseUrl.isNotEmpty && _model.isNotEmpty; + + /// Whether AI reminders (briefings) may be scheduled. On the ChatGPT route + /// only the ChatGPT sign-in counts: a BYOK key left over from before the + /// switch is not used there, so a signed-out ChatGPT coach is not set up. + bool get remindersConfigured => _useChatGpt ? configured : hasKey; /// Normalised base, no trailing slash. String get apiBase { + if (_useChatGpt) return kChatGptApiBase; var b = _baseUrl.trim(); while (b.endsWith('/')) { b = b.substring(0, b.length - 1); @@ -300,7 +308,11 @@ class CoachConfig extends ChangeNotifier { /// device unlocked — the only condition under which an EMPTY read is real /// evidence about what is stored. Future load({bool trusted = false}) async { + // Its own keychain item, and not awaited: a hung read of it must not hold + // back the BYOK settings. It notifies its own listeners, which are forwarded. + unawaited(chatgpt.load(trusted: trusted).catchError((_) {})); final prefs = await SharedPreferences.getInstance(); + _useChatGpt = prefs.getBool(_kUseChatGpt) ?? false; _baseUrl = prefs.getString(_kBaseUrl) ?? defaultBaseUrl; _api = switch (prefs.getString(_kApi)) { 'responses' => CoachApi.responses, @@ -396,7 +408,7 @@ class CoachConfig extends ChangeNotifier { /// otherwise, so it is safe on every resume, which is exactly when a phone /// that was locked during a background relaunch becomes readable again. Future refreshKeyOnResume() async { - if (!_keyUnreadable && !_keyUndetermined) return; + if (!_keyUnreadable && !_keyUndetermined && !chatgpt.unreadable) return; await load(trusted: true); } @@ -409,8 +421,13 @@ class CoachConfig extends ChangeNotifier { String? model, String? apiKey, int? timeoutSeconds, + bool? useChatGpt, }) async { final prefs = await SharedPreferences.getInstance(); + if (useChatGpt != null) { + _useChatGpt = useChatGpt; + await prefs.setBool(_kUseChatGpt, useChatGpt); + } if (baseUrl != null) { final previousOrigin = coachEndpointOrigin(_baseUrl); _baseUrl = baseUrl.trim().isEmpty ? defaultBaseUrl : baseUrl.trim(); @@ -462,7 +479,7 @@ class CoachConfig extends ChangeNotifier { // sitting there — delete it and retry once. A second failure is // rethrown as-is: this recovers the one known stale-item case, it // does not mask a keychain that is genuinely stuck. - if (!_isDuplicateItemError(e)) rethrow; + if (!isKeychainDuplicateItemError(e)) rethrow; await _secure.delete(key: _kKey, iOptions: _apple, mOptions: _macos); await _secure.write( key: _kKey, diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 6f3be1537..df4791233 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -26,6 +26,7 @@ import '../models/payloads.dart' show todayHeadlineOf; import '../sync/reset_gate.dart'; import 'coach_actions.dart'; import 'coach_chat_completions.dart'; +import 'coach_chatgpt.dart'; import 'coach_config.dart'; import 'coach_db.dart'; import 'coach_prompt.dart'; @@ -802,11 +803,19 @@ class CoachEngine { final model = body['model'] as String? ?? ''; final api = config.api; final apiBase = config.apiBase; + final chatgpt = config.usesChatGpt; + // Replayed reasoning is tied to who paid for it, so a ChatGPT-plan turn + // never replays an API-key turn's output on the same base, or another + // account's, or vice versa. + final replayBase = chatgpt + ? '$apiBase#chatgpt:${config.chatgpt.session?.sub ?? ''}' + : apiBase; try { body = switch (api) { - CoachApi.responses => CoachResponses.request(body, apiBase: apiBase), + CoachApi.responses => CoachResponses.request(body, apiBase: replayBase), CoachApi.chatCompletions => CoachChatCompletions.request(body), }; + if (chatgpt) body = chatGptRequest(body); // Recent Claude models reject sampling params with a 400, on Anthropic's // own endpoint and through any pass-through provider alike. Strip them for // exactly those model versions; older Claude models and every other @@ -830,24 +839,32 @@ class CoachEngine { 'time range, or start a new chat.', ); } + final bearer = chatgpt + ? await config.chatgpt.accessToken() + : (config.hasKey ? config.apiKey : null); final resp = await c .post( Uri.parse('$apiBase/${api.endpoint}'), headers: { - if (config.hasKey) 'Authorization': 'Bearer ${config.apiKey}', + if (bearer != null) 'Authorization': 'Bearer $bearer', 'content-type': 'application/json', }, body: payload, ) .timeout(config.requestTimeout); if (resp.statusCode != 200) { - throw CoachException( - 'Provider error (${resp.statusCode}): ${_briefErr(resp.body)}', - ); + throw CoachException(chatgpt + ? chatGptHttpError(resp.statusCode, utf8.decode(resp.bodyBytes)) + : 'Provider error (${resp.statusCode}): ${_briefErr(resp.body)}'); } final Object? j; try { - j = jsonDecode(utf8.decode(resp.bodyBytes)); + // The plan route only streams; the buffered body is its SSE events. + j = chatgpt + ? chatGptCompletedResponse(utf8.decode(resp.bodyBytes)) + : jsonDecode(utf8.decode(resp.bodyBytes)); + } on ChatGptException { + rethrow; } catch (_) { throw CoachException( 'Provider returned a non-JSON response. Check the API base URL — ' @@ -857,12 +874,14 @@ class CoachEngine { if (j is! Map) throw CoachException('Unexpected response from provider.'); return switch (api) { CoachApi.responses => CoachResponses.reply( - j.cast(), model, apiBase: apiBase), + j.cast(), model, apiBase: replayBase), CoachApi.chatCompletions => CoachChatCompletions.reply( j.cast()), }; } on FormatException catch (e) { throw CoachException(e.message); + } on ChatGptException catch (e) { + throw CoachException(e.message); } finally { if (client == null) c.close(); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8043da17e..3f7c9e49f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2247,6 +2247,7 @@ "settingsResetTitle": "Alles löschen?", "settingsResetBackupWarning": "App-Daten gelöscht. Die Backup-Bereinigung war unvollständig. Einige Dateien oder Ordnerzugriffe können erhalten bleiben.", "settingsResetPairingKeyWarning": "App-Daten gelöscht, aber ein Kopplungsschlüssel eines Sensors konnte nicht aus dem sicheren Speicher entfernt werden. Lösche alles erneut, um es noch einmal zu versuchen.", + "settingsResetChatGptWarning": "App-Daten gelöscht, aber die ChatGPT-Anmeldung wurde möglicherweise nicht vollständig entfernt. Trenne OpenStrap in den ChatGPT-Einstellungen, damit der Zugriff sicher widerrufen ist. Erneutes Löschen entfernt nur noch, was auf diesem Gerät übrig ist.", "settingsResetBody": "Diese App-Daten werden auf diesem Gerät dauerhaft gelöscht:\n\n· alle gemessenen Tage, Schlafdaten, Trainings und Routen\n· alle Laborwerte, Mahlzeiten, Medikamenteneinnahmen, Gewohnheiten, Atemübungen und eingetragenen Sätze\n· dein Journal, dein Zykluskalender und deine Baselines\n· dein Profil, alle Einstellungen und ein gespeicherter KI-Schlüssel\n· das Widget und alle geplanten Erinnerungen\n\nExportierte Kopien und Backups in früheren oder nicht verfügbaren Ordnern können erhalten bleiben. Lösche sie separat.\n\nDas Band wird entkoppelt und kann bereits übertragene Daten nicht erneut senden. Wenn du eine Kopie möchtest, exportiere vorher unter „Deine Daten“.", "settingsResetKeepData": "Meine Daten behalten", "settingsResetDeleteEverything": "Alles löschen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 724f9d181..5686693e1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11373,6 +11373,10 @@ "@settingsResetPairingKeyWarning": { "description": "Warning after reset when a sensor pairing key could not be removed from secure storage" }, + "settingsResetChatGptWarning": "App data deleted, but the ChatGPT sign-in may not be fully removed. Disconnect OpenStrap in ChatGPT settings to make sure its access is revoked. Deleting everything again only retries removing what is left on this device.", + "@settingsResetChatGptWarning": { + "description": "Warning after reset when the ChatGPT sign-in may not have been revoked or removed from secure storage" + }, "settingsResetBody": "This permanently deletes this app's data on this device:\n\n· every measured day, sleep, workout and route\n· every lab result, meal, medication dose, habit, breathing session and logged set\n· your journal, cycle log and what the app has learned as your usual\n· your profile, every preference and any stored AI key\n· the home-screen widget and every scheduled reminder\n\nExported copies and backups in previous or unavailable folders may remain. Delete those separately.\n\nThe band is unpaired, and it cannot re-send history it has already handed over. Export from Your data first if you want a copy.", "@settingsResetBody": { "description": "Confirmation dialog body listing exactly what a full reset deletes" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e99aba10e..44c78ec7c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2196,6 +2196,7 @@ "settingsResetTitle": "¿Eliminar todo?", "settingsResetBackupWarning": "Datos de la app eliminados. La limpieza de las copias de seguridad no se completó. Pueden quedar archivos o permisos de acceso a carpetas.", "settingsResetPairingKeyWarning": "Datos de la app eliminados, pero no se pudo quitar una clave de vinculación de un sensor del almacenamiento seguro. Vuelve a eliminar todo para reintentarlo.", + "settingsResetChatGptWarning": "Datos de la app eliminados, pero puede que el inicio de sesión de ChatGPT no se haya quitado del todo. Desconecta OpenStrap en los ajustes de ChatGPT para asegurarte de que su acceso queda revocado. Volver a eliminar todo solo reintenta quitar lo que quede en este dispositivo.", "settingsResetBody": "Esto elimina permanentemente los datos de esta app en este dispositivo:\n\n· cada día medido, sueño, entrenamiento y ruta\n· cada resultado de laboratorio, comida, dosis de medicación, hábito, sesión de respiración y serie registrada\n· tu diario, registro de ciclo y líneas base continuas\n· tu perfil, cada preferencia y cualquier clave de IA guardada\n· el widget de pantalla de inicio y cada recordatorio programado\n\nLas copias exportadas y los backups en carpetas anteriores o no disponibles pueden permanecer. Elimínalos por separado.\n\nEl band queda desvinculado, y no puede reenviar el historial que ya entregó. Exporta desde Tus datos primero si quieres una copia.", "settingsResetKeepData": "Conservar mis datos", "settingsResetDeleteEverything": "Eliminar todo", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 58840a3d3..08423710e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2184,6 +2184,7 @@ "settingsResetTitle": "Tout supprimer ?", "settingsResetBackupWarning": "Données de l’application supprimées. Le nettoyage des sauvegardes est incomplet. Certains fichiers ou droits d’accès aux dossiers peuvent rester.", "settingsResetPairingKeyWarning": "Données de l’application supprimées, mais une clé d’appairage d’un capteur n’a pas pu être retirée du stockage sécurisé. Supprimez tout à nouveau pour réessayer.", + "settingsResetChatGptWarning": "Données de l’application supprimées, mais la connexion ChatGPT n’a peut-être pas été entièrement retirée. Déconnectez OpenStrap dans les paramètres de ChatGPT pour vous assurer que son accès est révoqué. Tout supprimer à nouveau ne fait que réessayer d’effacer ce qui reste sur cet appareil.", "settingsResetBody": "Ceci supprime définitivement les données de cette application sur cet appareil :\n\n· chaque jour mesuré, sommeil, séance et itinéraire\n· chaque résultat de labo, repas, dose de médicament, habitude, séance de respiration et série enregistrée\n· votre journal, suivi de cycle et lignes de base glissantes\n· votre profil, chaque préférence et toute clé IA stockée\n· le widget d’écran d’accueil et chaque rappel programmé\n\nLes copies exportées et les sauvegardes dans des dossiers précédents ou indisponibles peuvent rester. Supprimez-les séparément.\n\nLe bracelet est dissocié et ne peut pas renvoyer l’historique déjà transmis. Exportez depuis Vos données d’abord si vous voulez une copie.", "settingsResetKeepData": "Conserver mes données", "settingsResetDeleteEverything": "Tout supprimer", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 027cccad3..39a97f40a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2184,6 +2184,7 @@ "settingsResetTitle": "सब कुछ मिटा दें?", "settingsResetBackupWarning": "ऐप का डेटा मिटा दिया गया। बैकअप की सफ़ाई पूरी नहीं हुई। कुछ फ़ाइलें या फ़ोल्डर तक पहुंच की अनुमति रह सकती है।", "settingsResetPairingKeyWarning": "ऐप का डेटा मिटा दिया गया, लेकिन किसी सेंसर की पेयरिंग कुंजी सुरक्षित स्टोरेज से नहीं हटाई जा सकी। फिर से कोशिश करने के लिए सब कुछ दोबारा मिटाएँ।", + "settingsResetChatGptWarning": "ऐप का डेटा मिटा दिया गया, लेकिन ChatGPT साइन-इन शायद पूरी तरह नहीं हटा। पहुँच पक्का रद्द करने के लिए ChatGPT सेटिंग्स में OpenStrap को डिस्कनेक्ट करें। सब कुछ दोबारा मिटाने से सिर्फ़ इस डिवाइस पर बचा हिस्सा हटाने की फिर कोशिश होती है।", "settingsResetBody": "यह इस डिवाइस पर इस ऐप का डेटा स्थायी रूप से मिटा देता है:\n\n· हर मापा गया दिन, नींद, वर्कआउट और रूट\n· हर लैब परिणाम, भोजन, दवा की खुराक, आदत, श्वास सत्र और लॉग किया गया सेट\n· आपकी डायरी, चक्र लॉग और चलती बेसलाइन\n· आपकी प्रोफ़ाइल, हर प्राथमिकता और कोई भी सहेजी गई AI कुंजी\n· होम-स्क्रीन विजेट और हर शेड्यूल किया गया रिमाइंडर\n\nएक्सपोर्ट की गई प्रतियां और पुराने या उपलब्ध न होने वाले फ़ोल्डरों में बैकअप रह सकते हैं। उन्हें अलग से मिटाएं।\n\nबैंड की जोड़ी हटा दी जाती है, और वह पहले से भेजा गया इतिहास दोबारा नहीं भेज सकता। यदि आपको एक प्रति चाहिए तो पहले 'आपका डेटा' से एक्सपोर्ट करें।", "settingsResetKeepData": "मेरा डेटा रखें", "settingsResetDeleteEverything": "सब कुछ मिटाएँ", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c1d4b9fe6..b735545ba 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -6774,6 +6774,7 @@ "coachApiFormatLabel": "API", "settingsResetBackupWarning": "Данные приложения удалены. Очистка резервных копий завершилась не полностью: некоторые файлы или доступ к папке могут остаться.", "settingsResetPairingKeyWarning": "Данные приложения удалены, но ключ сопряжения датчика не удалось удалить из защищённого хранилища. Удалите всё ещё раз, чтобы повторить попытку.", + "settingsResetChatGptWarning": "Данные приложения удалены, но вход в ChatGPT, возможно, удалён не полностью. Отключите OpenStrap в настройках ChatGPT, чтобы его доступ точно был отозван. Повторное удаление всего лишь ещё раз попробует убрать то, что осталось на этом устройстве.", "dataBackupFolder": "Папка резервных копий", "dataBackupFolderDefault": "Папка приложения (по умолчанию)", "dataBackupFolderSub": "Выберите, куда сохранять новые резервные копии. Существующие копии останутся на месте", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 786598d17..d9969ca7f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2184,6 +2184,7 @@ "settingsResetTitle": "删除全部数据?", "settingsResetBackupWarning": "应用数据已删除。备份清理未完成,部分文件或文件夹访问权限可能仍然保留。", "settingsResetPairingKeyWarning": "应用数据已删除,但某个传感器的配对密钥无法从安全存储中移除。请再次删除全部以重试。", + "settingsResetChatGptWarning": "应用数据已删除,但 ChatGPT 登录可能未完全移除。请在 ChatGPT 设置中断开 OpenStrap,以确保其访问权限已撤销。再次删除全部只会重试清除此设备上剩余的内容。", "settingsResetBody": "此操作将永久删除此设备上本应用的数据:\n\n· 每一天的测量数据、睡眠、锻炼和路线\n· 每一项化验结果、饮食、用药剂量、习惯、呼吸训练和记录的组数\n· 你的日记、周期记录和滚动基线\n· 你的个人资料、所有偏好设置以及保存的任何 AI 密钥\n· 主屏幕小组件和所有已安排的提醒\n\n导出的副本以及旧文件夹或无法访问的文件夹中的备份可能仍然保留。请单独删除这些文件。\n\n手环会解除配对,并且无法重新发送已经交出的历史数据。如果需要保留副本,请先在“你的数据”中导出。", "settingsResetKeepData": "保留我的数据", "settingsResetDeleteEverything": "删除全部", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7398bec59..b581d5318 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1345,6 +1345,10 @@ class AppState extends ChangeNotifier { /// confirmation would otherwise read as a clean wipe. bool resetLeftPairingKeys = false; + /// Set by [resetAllData] when the ChatGPT sign-in may outlive it: OpenAI did + /// not confirm the revocation, or the keychain item could not be deleted. + bool resetLeftChatGpt = false; + /// "Delete everything", and mean it. /// /// The old reset walked `availableDays()` — the non-skipped DERIVED days — @@ -1454,10 +1458,20 @@ class AppState extends ChangeNotifier { _log('[reset] widget clear failed: $e'); } try { - await coachConfig?.save(apiKey: ''); // deletes the keychain entry + await coachConfig?.save( + apiKey: '', // deletes the keychain entry + useChatGpt: false, + model: '', + ); } catch (e) { _log('[reset] keychain clear failed: $e'); } + try { + resetLeftChatGpt = !(await coachConfig?.chatgpt.forgetAll() ?? true); + } catch (e) { + resetLeftChatGpt = true; + _log('[reset] ChatGPT sign-in clear failed: $e'); + } // Coach chats are files, not rows, so wipeAll does not reach them. try { final left = await CoachEngine.deleteAllSavedSessions(); diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 3e92ba294..81f088ecf 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -549,6 +549,14 @@ Future _confirmReset(BuildContext c, AppState app) async { 'Some files or folder access may remain.'), )); } + if (app.resetLeftChatGpt && messenger.mounted) { + messenger.showSnackBar(SnackBar( + content: Text(l?.settingsResetChatGptWarning ?? + 'App data deleted, but the ChatGPT sign-in may not be fully removed. ' + 'Disconnect OpenStrap in ChatGPT settings to make sure its access is revoked. ' + 'Deleting everything again only retries removing what is left on this device.'), + )); + } if (app.resetLeftPairingKeys && messenger.mounted) { messenger.showSnackBar(SnackBar( content: Text(l?.settingsResetPairingKeyWarning ?? diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 75e1410d2..faefdb557 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -28,6 +28,8 @@ import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../ai/briefing.dart' show currentBriefingPeriod; +import 'package:url_launcher/url_launcher.dart'; +import '../../coach/coach_chatgpt.dart'; import '../../coach/coach_config.dart'; import '../../coach/coach_engine.dart'; import '../../state/app_state.dart'; @@ -461,7 +463,11 @@ class _CoachScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: S.x4), child: NavBar( l?.coachNavTitle ?? 'Coach', - sub: cfg.configured ? cfg.model : (l?.coachNotSetUp ?? 'Not set up'), + sub: cfg.configured + ? (cfg.usesChatGpt + ? '${cfg.model} · ${uiText(c, 'Using ChatGPT plan')}' + : cfg.model) + : (l?.coachNotSetUp ?? 'Not set up'), trailing: Pressable( semanticLabel: l?.coachMenuSemantic ?? 'Chats and AI settings', onTap: _menu, @@ -928,6 +934,12 @@ class _CoachSetupState extends State { late final TextEditingController _search; late final TextEditingController _timeout; late CoachApi _api; + + /// "ChatGPT" is chosen instead of a base URL + key. Saved by [_save] only; + /// signing in alone does not switch how the coach is paid for. + late bool _chatgpt; + late final ChatGptAuth _chatgptAuth; // for dispose, which must not read context + bool _signingIn = false; /// The model Save will store when nothing has been typed: the saved one at /// first, then whatever list row was last tapped. Typing in [_search] /// clears it (see [_onSearchChanged]) so a typed id is never silently @@ -964,6 +976,8 @@ class _CoachSetupState extends State { _search = TextEditingController(); _timeout = TextEditingController(text: cfg.timeoutSeconds.toString()); _api = cfg.api; + _chatgpt = cfg.usesChatGpt; + _chatgptAuth = cfg.chatgpt; _model = cfg.model; _keyOrigin = coachEndpointOrigin(cfg.baseUrl); // The base URL decides which preset is lit and whether a key is needed, and @@ -1019,6 +1033,7 @@ class _CoachSetupState extends State { @override void dispose() { + if (_signingIn) _chatgptAuth.cancelSignIn(); _base.dispose(); _key.dispose(); _search.dispose(); @@ -1026,9 +1041,10 @@ class _CoachSetupState extends State { super.dispose(); } - bool get _isLocal => isLocalCoachHost(_base.text.trim()); + bool get _isLocal => !_chatgpt && isLocalCoachHost(_base.text.trim()); Future _fetch() async { + final chatgpt = _chatgptAuth; setState(() { _loading = true; _msg = null; @@ -1036,9 +1052,14 @@ class _CoachSetupState extends State { // A reply for an endpoint the user has since edited away from would list // models the new one may not serve. final asked = _base.text; + // Switching between ChatGPT and a preset need not change the base URL. + final askedChatgpt = _chatgpt; + bool stale() => _base.text != asked || _chatgpt != askedChatgpt; try { - final ids = await CoachEngine.fetchModels(asked, _key.text); - if (!mounted || _base.text != asked) return; + final ids = askedChatgpt + ? await chatgpt.listModels() + : await CoachEngine.fetchModels(asked, _key.text); + if (!mounted || stale()) return; final l = AppLocalizations.of(context); setState(() { _models = ids; @@ -1048,11 +1069,11 @@ class _CoachSetupState extends State { : (l?.coachModelsFound(ids.length) ?? '${ids.length} models. Tap one.'); }); } catch (e) { - if (!mounted || _base.text != asked) return; + if (!mounted || stale()) return; final l = AppLocalizations.of(context); setState( - () => _msg = e is CoachException - ? e.message + () => _msg = e is CoachException || e is ChatGptException + ? '$e' : (l?.coachEndpointUnreachable('$e') ?? 'Could not reach that endpoint: $e'), ); } finally { @@ -1075,8 +1096,15 @@ class _CoachSetupState extends State { // the existing timeout untouched (CoachConfig also rejects <=0) rather // than blocking the rest of the save over one bad field. final timeoutSeconds = _isLocal ? int.tryParse(_timeout.text.trim()) : null; + if (_chatgpt) { + // The BYOK fields are left exactly as they were, for switching back. + await cfg.save(useChatGpt: true, model: chosen); + if (mounted && nav.canPop()) nav.pop(); + return; + } try { await cfg.save( + useChatGpt: false, baseUrl: _base.text, apiKey: coachApiKeyToSave( keyText: _key.text, @@ -1098,6 +1126,255 @@ class _CoachSetupState extends State { if (mounted && nav.canPop()) nav.pop(); } + Future _closeBrowser() async { + try { + if (await supportsCloseForLaunchMode(LaunchMode.inAppBrowserView)) { + await closeInAppWebView(); + } + } catch (_) {} + } + + /// The redirect lands on a loopback listener inside this app, so the page + /// opens in an in-app browser (SFSafariViewController / Custom Tab): with + /// the system browser in front, iOS suspends the app and the listener dies. + Future _signInChatGpt() async { + final chatgpt = _chatgptAuth; + if (!await confirmChatGptTerms(context) || !mounted) return; + setState(() { + _signingIn = true; + _msg = null; + }); + try { + await chatgpt.signIn( + (uri) => launchUrl(uri, mode: LaunchMode.inAppBrowserView), + ); + await _closeBrowser(); + if (!mounted) return; + setState(() { + _chatgpt = true; + _models = const []; + _model = ''; + if (!chatgpt.sharing) { + _msg = uiText(context, + 'Signed in, but ChatGPT plan usage was not allowed. Continue with ChatGPT again and allow it to use your plan here.'); + } + }); + if (!chatgpt.sharing) return; + if (await chatgpt.takeWelcome() && mounted) await _welcome(); + if (mounted) await _fetch(); + } catch (e) { + await _closeBrowser(); + if (mounted) { + setState(() => _msg = e is ChatGptException ? e.message : 'ChatGPT sign-in failed: $e'); + } + } finally { + if (mounted) setState(() => _signingIn = false); + } + } + + /// Leaves the coach set to ChatGPT, now not configured, rather than + /// quietly falling back to an API key the user may be billed for. + Future _signOutChatGpt() async { + final revoked = await _chatgptAuth.signOut(); + if (!mounted) return; + setState(() { + _models = const []; + _msg = revoked + ? uiText(context, 'Signed out of ChatGPT.') + : uiText(context, + 'Signed out here, but ChatGPT did not confirm it. You can also disconnect OpenStrap in ChatGPT settings.'); + }); + } + + Future _welcome() => showModalBottomSheet( + context: context, + sheetAnimationStyle: sheetMotion(context), + backgroundColor: P.of(context).card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(R.xxl)), + ), + builder: (s) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(S.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(uiText(s, 'You’re using your ChatGPT plan'), + style: F.head.copyWith(color: P.of(s).ink)), + const SizedBox(height: S.x2), + Text( + uiText(s, + 'Eligible usage in this app uses your ChatGPT plan. Manage usage in your ChatGPT settings.'), + style: F.cap.copyWith(color: P.of(s).ink2, height: 1.5), + ), + const SizedBox(height: S.x5), + BigButton( + uiText(s, 'Got it'), + color: kCoachAccent, + onTap: () => Navigator.of(s).pop(), + ), + ], + ), + ), + ), + ); + + Widget _presetTile(BuildContext c, P p, _Preset preset) => Padding( + padding: const EdgeInsets.only(bottom: S.x2), + child: Surface( + elevation: 0, + color: !_chatgpt && + _base.text.trim() == preset.baseUrl + ? p.wash( + preset.local ? C.green : kCoachAccent, + ) + : p.card2, + onTap: () => setState(() { + // BEFORE assigning _base.text: that assignment + // fires _onBaseChanged synchronously, which + // may set _msg to explain a cleared key — + // clearing _msg after it runs would silently + // discard that explanation. + _msg = null; + // A sign-in still out would otherwise land later and switch the + // coach back to ChatGPT over this choice. + if (_signingIn) _chatgptAuth.cancelSignIn(); + _chatgpt = false; + _base.text = preset.baseUrl; + _api = CoachApi.defaultFor(preset.baseUrl); + _models = const []; + _model = ''; + }), + child: Row( + children: [ + Icon( + preset.local + ? LucideIcons.house + : LucideIcons.cloud, + size: 17, + color: p.on( + preset.local ? C.green : kCoachAccent, + ), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + preset.label, + style: F.body.copyWith( + color: p.ink, + fontWeight: FontWeight.w600, + ), + ), + Text( + preset.sub, + style: F.cap.copyWith(color: p.ink3), + ), + ], + ), + ), + ], + ), + ), + ); + + Widget _chatGptCard(BuildContext c, P p) { + final chatgpt = c.watch().chatgpt; + final session = chatgpt.session; + final sub = session == null + ? uiText(c, 'Use your ChatGPT plan') + : chatgpt.sharing + ? '${uiText(c, 'Using your ChatGPT plan')} · ${session.email}' + : '${uiText(c, 'Plan usage not allowed')} · ${session.email}'; + return Padding( + padding: const EdgeInsets.only(bottom: S.x2), + child: Surface( + elevation: 0, + color: _chatgpt ? p.wash(kCoachAccent) : p.card2, + onTap: _signingIn + ? null + : () { + // Selected before a sign-in starts too, so its fields (and the + // "Cancel sign-in" button) show while the browser is up. + setState(() { + _msg = null; + _chatgpt = true; + _models = const []; + _model = ''; + }); + if (!chatgpt.sharing) _signInChatGpt(); + }, + child: Row( + children: [ + Icon(LucideIcons.sparkles, size: 17, color: p.on(kCoachAccent)), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ChatGPT', + style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600)), + Text(sub, style: F.cap.copyWith(color: p.ink3)), + ], + ), + ), + ], + ), + ), + ); + } + + List _chatGptFields(BuildContext c, P p) { + final chatgpt = c.watch().chatgpt; + return [ + const SizedBox(height: S.x2), + Text( + uiText(c, + 'Complete eligible AI requests in this app with usage included in your ChatGPT plan or credits balance. OpenStrap never charges for this.'), + style: F.cap.copyWith(color: p.ink3, height: 1.5), + ), + const SizedBox(height: S.x4), + if (!chatgpt.sharing) + BigButton( + _signingIn + ? uiText(c, 'Cancel sign-in') + : uiText(c, 'Continue with ChatGPT'), + icon: _signingIn ? LucideIcons.x : LucideIcons.logIn, + color: kCoachAccent, + soft: _signingIn, + onTap: _signingIn ? _chatgptAuth.cancelSignIn : _signInChatGpt, + ), + if (chatgpt.signedIn) ...[ + if (!chatgpt.sharing) const SizedBox(height: S.x3), + BigButton( + uiText(c, 'Manage usage'), + icon: LucideIcons.externalLink, + color: kCoachAccent, + soft: true, + onTap: () => launchUrl(Uri.parse(kChatGptUsageUrl), + mode: LaunchMode.externalApplication), + ), + const SizedBox(height: S.x3), + BigButton( + uiText(c, 'Sign out of ChatGPT'), + icon: LucideIcons.logOut, + soft: true, + onTap: _signOutChatGpt, + ), + ], + const SizedBox(height: S.x3), + Text( + uiText(c, + 'Your questions and the rows the coach reads are sent to OpenAI under your ChatGPT account, where your ChatGPT data controls, including model training, may apply. Check them in ChatGPT settings. See exactly what is sent on "What was sent".'), + style: F.cap.copyWith(color: p.ink3, height: 1.5), + ), + ]; + } + @override Widget build(BuildContext c) { final p = P.of(c); @@ -1127,151 +1404,109 @@ class _CoachSetupState extends State { l?.coachWhereModelRuns ?? 'Where the model runs', Column( children: [ - for (final preset in _presets(c)) - Padding( - padding: const EdgeInsets.only(bottom: S.x2), - child: Surface( - elevation: 0, - color: _base.text.trim() == preset.baseUrl - ? p.wash( - preset.local ? C.green : kCoachAccent, - ) - : p.card2, - onTap: () => setState(() { - // BEFORE assigning _base.text: that assignment - // fires _onBaseChanged synchronously, which - // may set _msg to explain a cleared key — - // clearing _msg after it runs would silently - // discard that explanation. - _msg = null; - _base.text = preset.baseUrl; - _api = CoachApi.defaultFor(preset.baseUrl); - _models = const []; - _model = ''; - }), - child: Row( - children: [ - Icon( - preset.local - ? LucideIcons.house - : LucideIcons.cloud, - size: 17, - color: p.on( - preset.local ? C.green : kCoachAccent, - ), + // After the local presets: they are the only option + // where the data never leaves the user's control. + for (final preset in _presets(c).where((x) => x.local)) + _presetTile(c, p, preset), + _chatGptCard(c, p), + for (final preset in _presets(c).where((x) => !x.local)) + _presetTile(c, p, preset), + ], + ), + ), + if (_chatgpt) + ..._chatGptFields(c, p) + else ...[ + const SizedBox(height: S.x2), + OsTextField( + controller: _base, + label: l?.coachBaseUrlLabel ?? 'Base URL', + hint: 'http://localhost:11434/v1', + ), + const SizedBox(height: S.x4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l?.coachApiFormatLabel ?? 'API', + style: F.over.copyWith(color: p.ink3)), + const SizedBox(height: S.x2), + Container( + padding: const EdgeInsets.symmetric(horizontal: S.x3), + decoration: BoxDecoration( + color: p.card, + borderRadius: R.rMd, + border: Border.all(color: p.line), + ), + child: Semantics( + label: l?.coachApiFormatLabel ?? 'API', + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _api, + isExpanded: true, + dropdownColor: p.card, + style: F.body.copyWith(color: p.ink), + items: const [ + DropdownMenuItem( + value: CoachApi.responses, + child: Text('Responses'), ), - const SizedBox(width: S.x3), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - preset.label, - style: F.body.copyWith( - color: p.ink, - fontWeight: FontWeight.w600, - ), - ), - Text( - preset.sub, - style: F.cap.copyWith(color: p.ink3), - ), - ], - ), + DropdownMenuItem( + value: CoachApi.chatCompletions, + child: Text('Chat Completions'), ), ], + onChanged: (api) { + if (api != null) setState(() => _api = api); + }, ), ), ), - ], - ), - ), - const SizedBox(height: S.x2), - OsTextField( - controller: _base, - label: l?.coachBaseUrlLabel ?? 'Base URL', - hint: 'http://localhost:11434/v1', - ), - const SizedBox(height: S.x4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l?.coachApiFormatLabel ?? 'API', - style: F.over.copyWith(color: p.ink3)), - const SizedBox(height: S.x2), - Container( - padding: const EdgeInsets.symmetric(horizontal: S.x3), - decoration: BoxDecoration( - color: p.card, - borderRadius: R.rMd, - border: Border.all(color: p.line), ), - child: Semantics( - label: l?.coachApiFormatLabel ?? 'API', - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _api, - isExpanded: true, - dropdownColor: p.card, - style: F.body.copyWith(color: p.ink), - items: const [ - DropdownMenuItem( - value: CoachApi.responses, - child: Text('Responses'), - ), - DropdownMenuItem( - value: CoachApi.chatCompletions, - child: Text('Chat Completions'), - ), - ], - onChanged: (api) { - if (api != null) setState(() => _api = api); - }, - ), - ), + const SizedBox(height: S.x2), + Text( + l?.coachApiFormatHelp ?? + 'Choose the API your endpoint supports. Responses ' + 'supports OpenAI reasoning with tools.', + style: F.cap.copyWith(color: p.ink3, height: 1.5), ), - ), - const SizedBox(height: S.x2), - Text( - l?.coachApiFormatHelp ?? - 'Choose the API your endpoint supports. Responses ' - 'supports OpenAI reasoning with tools.', - style: F.cap.copyWith(color: p.ink3, height: 1.5), - ), - ], - ), - const SizedBox(height: S.x4), - OsTextField( - controller: _key, - label: _isLocal - ? (l?.coachApiKeyLocalLabel ?? 'API key (not needed locally)') - : (l?.coachApiKeyLabel ?? 'API key'), - hint: 'sk-…', - ), - const SizedBox(height: S.x3), - // The one sentence that decides whether a cloud key is a - // reasonable choice. It is here, next to the field, and not - // in a settings page nobody opens. Private hosts only: a - // 100.64.0.0/10 address needs no key but may not be yours. - Text( - isPrivateCoachHost(_base.text.trim()) - ? (l?.coachLocalDataNote ?? - 'Your questions and the rows the coach reads stay on ' - 'your own machine.') - : (l?.coachCloudDataNote ?? - 'Your questions and the rows the coach reads are sent ' - 'to this endpoint. See exactly what that is on ' - '"What was sent".'), - style: F.cap.copyWith(color: p.ink3, height: 1.5), - ), + ], + ), + const SizedBox(height: S.x4), + OsTextField( + controller: _key, + label: _isLocal + ? (l?.coachApiKeyLocalLabel ?? 'API key (not needed locally)') + : (l?.coachApiKeyLabel ?? 'API key'), + hint: 'sk-…', + ), + const SizedBox(height: S.x3), + // The one sentence that decides whether a cloud key is a + // reasonable choice. It is here, next to the field, and not + // in a settings page nobody opens. Private hosts only: a + // 100.64.0.0/10 address needs no key but may not be yours. + Text( + isPrivateCoachHost(_base.text.trim()) + ? (l?.coachLocalDataNote ?? + 'Your questions and the rows the coach reads stay on ' + 'your own machine.') + : (l?.coachCloudDataNote ?? + 'Your questions and the rows the coach reads are sent ' + 'to this endpoint. See exactly what that is on ' + '"What was sent".'), + style: F.cap.copyWith(color: p.ink3, height: 1.5), + ), + ], const SizedBox(height: S.x4), BigButton( _loading ? (l?.coachAsking ?? 'Asking…') : (l?.coachListModels ?? 'List models'), icon: LucideIcons.refreshCw, color: kCoachAccent, soft: true, - onTap: _loading ? null : _fetch, + onTap: _loading || + (_chatgpt && + !context.watch().chatgpt.sharing) + ? null + : _fetch, ), if (_msg != null) ...[ const SizedBox(height: S.x3), @@ -1364,3 +1599,69 @@ class _CoachSetupState extends State { ); } } + +/// Shown before every ChatGPT sign-in. Signing in puts the coach's requests +/// under the user's own OpenAI account, so OpenAI's terms and usage policies +/// apply to them; the sign-in only starts after an explicit Continue. +/// Returns false when the sheet is cancelled or dismissed. +Future confirmChatGptTerms(BuildContext context) async { + final ok = await showModalBottomSheet( + context: context, + isScrollControlled: true, + sheetAnimationStyle: sheetMotion(context), + backgroundColor: P.of(context).card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(R.xxl)), + ), + builder: (s) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(S.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(uiText(s, 'Before you sign in'), + style: F.head.copyWith(color: P.of(s).ink)), + const SizedBox(height: S.x2), + Text( + uiText(s, + 'Signing in uses your own ChatGPT account and plan. Your use of it here is covered by OpenAI’s Terms of Use and Usage Policies, so please read them before you continue.'), + style: F.cap.copyWith(color: P.of(s).ink2, height: 1.5), + ), + const SizedBox(height: S.x4), + BigButton( + uiText(s, 'OpenAI Terms of Use'), + icon: LucideIcons.externalLink, + color: kCoachAccent, + soft: true, + onTap: () => launchUrl(Uri.parse(kOpenAiTermsUrl), + mode: LaunchMode.externalApplication), + ), + const SizedBox(height: S.x3), + BigButton( + uiText(s, 'OpenAI Usage Policies'), + icon: LucideIcons.externalLink, + color: kCoachAccent, + soft: true, + onTap: () => launchUrl(Uri.parse(kOpenAiUsagePoliciesUrl), + mode: LaunchMode.externalApplication), + ), + const SizedBox(height: S.x5), + BigButton( + uiText(s, 'I’ve read them, continue'), + color: kCoachAccent, + onTap: () => Navigator.of(s).pop(true), + ), + const SizedBox(height: S.x3), + BigButton( + uiText(s, 'Cancel'), + soft: true, + onTap: () => Navigator.of(s).pop(false), + ), + ], + ), + ), + ), + ); + return ok ?? false; +} diff --git a/test/chatgpt_terms_notice_test.dart b/test/chatgpt_terms_notice_test.dart new file mode 100644 index 000000000..623bb4e65 --- /dev/null +++ b/test/chatgpt_terms_notice_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/coach.dart' show confirmChatGptTerms; + +// ChatGPT sign-in only starts after the user has been pointed at OpenAI's +// terms and usage policies and chosen to continue. +void main() { + Future> open(WidgetTester tester) async { + tester.view.physicalSize = const Size(390 * 3, 900 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + final results = []; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (c) => TextButton( + onPressed: () async => results.add(await confirmChatGptTerms(c)), + child: const Text('open'), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return results; + } + + testWidgets('the notice names both OpenAI documents before sign-in', + (tester) async { + await open(tester); + expect(find.text('Before you sign in'), findsOneWidget); + expect(find.textContaining('your own ChatGPT account'), findsOneWidget); + expect(find.text('OpenAI Terms of Use'), findsOneWidget); + expect(find.text('OpenAI Usage Policies'), findsOneWidget); + }); + + testWidgets('cancel does not start sign-in', (tester) async { + final results = await open(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('dismissing the sheet does not start sign-in', (tester) async { + final results = await open(tester); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('continue starts sign-in', (tester) async { + final results = await open(tester); + await tester.tap(find.text('I’ve read them, continue')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); +} diff --git a/test/coach_chatgpt_test.dart b/test/coach_chatgpt_test.dart new file mode 100644 index 000000000..4e4c1de02 --- /dev/null +++ b/test/coach_chatgpt_test.dart @@ -0,0 +1,1029 @@ +// "Sign in with ChatGPT" plan usage: the OAuth round trip through a real +// loopback listener, token refresh, and the coach's one provider POST routed +// to OpenAI's streaming-only Responses route with the plan's access token. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:http/testing.dart'; +import 'package:openstrap_edge/coach/coach_chatgpt.dart'; +import 'package:openstrap_edge/coach/coach_chatgpt_jwks.dart'; +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:openstrap_edge/sync/reset_gate.dart'; +import 'package:pointycastle/export.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Flutter's test binding fakes HttpClient; this one really reaches loopback. +class _RealHttpOverrides extends HttpOverrides {} + +// A real RS256 key pair, so the ID token goes through the same signature +// check as OpenAI's. Generated once: 2048-bit generation is the slow part. +final AsymmetricKeyPair _rsa = () { + final rnd = FortunaRandom() + ..seed(KeyParameter(Uint8List.fromList(List.generate(32, (i) => i * 7 + 1)))); + final gen = RSAKeyGenerator() + ..init(ParametersWithRandom(RSAKeyGeneratorParameters(BigInt.from(65537), 2048, 64), rnd)); + final pair = gen.generateKeyPair(); + return AsymmetricKeyPair(pair.publicKey as RSAPublicKey, pair.privateKey as RSAPrivateKey); +}(); + +const _kid = 'test-kid'; + +String _b64(Object o) => base64Url.encode(utf8.encode(jsonEncode(o))).replaceAll('=', ''); + +String _b64Int(BigInt v) { + final bytes = []; + while (v > BigInt.zero) { + bytes.insert(0, (v & BigInt.from(0xff)).toInt()); + v = v >> 8; + } + return base64Url.encode(bytes).replaceAll('=', ''); +} + +String _sign(String header, String claims, [RSAPrivateKey? key]) { + final signer = RSASigner(SHA256Digest(), '0609608648016503040201') + ..init(true, PrivateKeyParameter(key ?? _rsa.privateKey)); + final sig = signer.generateSignature(Uint8List.fromList(ascii.encode('$header.$claims'))); + return base64Url.encode(sig.bytes).replaceAll('=', ''); +} + +String _jwt(Map claims, {Map? header}) { + final h = _b64(header ?? {'alg': 'RS256', 'kid': _kid, 'typ': 'JWT'}); + final c = _b64(claims); + return '$h.$c.${_sign(h, c)}'; +} + +String _jwksBody([String kid = _kid]) => jsonEncode({ + 'keys': [ + { + 'kty': 'RSA', + 'kid': kid, + 'alg': 'RS256', + 'use': 'sig', + 'n': _b64Int(_rsa.publicKey.modulus!), + 'e': _b64Int(_rsa.publicKey.exponent!), + }, + ], + }); + +/// Serves OpenAI's discovery document and key set; null for anything else. +http.Response? _oidc(http.BaseRequest req, {String kid = _kid}) { + final u = req.url.toString(); + if (u == '$kChatGptIssuer/.well-known/openid-configuration') { + return http.Response( + jsonEncode({'issuer': kChatGptIssuer, 'jwks_uri': '$kChatGptIssuer/.well-known/jwks.json'}), + 200); + } + if (u == '$kChatGptIssuer/.well-known/jwks.json') return http.Response(_jwksBody(kid), 200); + return null; +} + +String _idToken(String clientId, String nonce, {String iss = kChatGptIssuer, int? exp}) => + _jwt({ + 'iss': iss, + 'aud': clientId, + 'sub': 'user-1', + 'email': 'me@example.com', + 'nonce': nonce, + 'exp': exp ?? DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, + }); + +const _fullScope = 'chatgpt.tokens.use.direct email offline_access openid profile resource.invoke'; + +ChatGptSession _session({DateTime? expiresAt, String scope = _fullScope}) => ChatGptSession( + sub: 'user-1', + email: 'me@example.com', + idToken: 'old-id-token', + accessToken: 'at-1', + refreshToken: 'rt-1', + scope: scope, + expiresAt: expiresAt ?? DateTime.now().add(const Duration(hours: 1)), + ); + +String _sse(List> events) => + events.map((e) => 'event: ${e['type']}\ndata: ${jsonEncode(e)}\n\n').join(); + +Map _completed(String text) => { + 'type': 'response.completed', + 'response': { + 'status': 'completed', + 'output': [ + { + 'type': 'message', + 'status': 'completed', + 'role': 'assistant', + 'content': [ + {'type': 'output_text', 'text': text, 'annotations': []}, + ], + }, + ], + }, + }; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + late Map keychain; + Completer? writeGate; // hangs keychain writes until completed + + setUp(() { + keychain = {}; + writeGate = null; + SharedPreferences.setMockInitialValues({}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = (call.arguments as Map?) ?? const {}; + switch (call.method) { + case 'read': + return keychain[args['key']]; + case 'write': + await writeGate?.future; + keychain[args['key'] as String] = args['value'] as String; + case 'delete': + keychain.remove(args['key']); + case 'containsKey': + return keychain.containsKey(args['key']); + } + return null; + }); + }); + + tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + + test('PKCE challenge is the RFC 7636 S256 transform', () { + expect(chatGptCodeChallenge('dBjftJeZ4CVP-mJ92K9qn6MWHFJM1yN_GgcsRmJN0_w'), + 'BCCYQ8sMKFQG5yknl8BJ9-8mva0FhJQJZSboXfUVfsc'); + }); + + group('sign-in', () { + late List> tokenForms; + late ChatGptAuth auth; + late http.Client loopback; + + setUp(() { + tokenForms = []; + loopback = IOClient(_RealHttpOverrides().createHttpClient(null)); + addTearDown(loopback.close); + }); + + /// Plays the browser: first a stray hit with the wrong state, which must + /// not end the sign-in, then the real redirect. + Future browser(Uri authorize, {String? clientId, String? error}) async { + final q = authorize.queryParameters; + final redirect = Uri.parse(q['redirect_uri']!); + expect(redirect.host, '127.0.0.1'); + expect(redirect.path, '/auth/callback'); + final stray = await loopback.get(redirect.replace(queryParameters: {'state': 'nope', 'code': 'x'})); + expect(stray.statusCode, 400); + unawaited(loopback.get(redirect.replace(queryParameters: { + 'state': q['state']!, + if (error != null) 'error': error else 'code': 'code-1', + 'client_id': ?clientId, + }))); + return true; + } + + ChatGptAuth makeAuth(String Function() nonce) => ChatGptAuth( + client: MockClient((req) async { + final oidc = _oidc(req); + if (oidc != null) return oidc; + expect(req.url.toString(), '$kChatGptIssuer/api/accounts/oauth/token'); + tokenForms.add(req.bodyFields); + if (req.bodyFields['grant_type'] == 'authorization_code') { + expect(req.bodyFields['code'], 'code-1'); + } + return http.Response( + jsonEncode({ + 'access_token': 'at-1', + 'refresh_token': 'rt-1', + 'id_token': _idToken(req.bodyFields['client_id']!, nonce()), + 'scope': _fullScope, + 'expires_in': 3600, + }), + 200, + ); + }), + ); + + test('registers, exchanges the code, and signs back in to the same registration', () async { + late Uri first, second; + auth = makeAuth(() => (second.hasQuery ? second : first).queryParameters['nonce']!); + second = Uri(); + await auth.signIn((u) { + first = u; + return browser(u, clientId: 'oaiapp_1'); + }); + final q = first.queryParameters; + expect(q['client_id'], 'dynamic_agent_client'); + expect(q['agent_name_hint'], 'OpenStrap Edge'); + expect(q['ext_agent_host_id'], startsWith('urn:uuid:')); + expect(q['scope']!.split(' '), containsAll(['offline_access', 'resource.invoke', kChatGptPlanScope])); + expect(q['resource'], kChatGptApiBase); + expect(q['code_challenge_method'], 'S256'); + expect(tokenForms.single, containsPair('client_id', 'oaiapp_1')); + expect(tokenForms.single, containsPair('grant_type', 'authorization_code')); + expect(tokenForms.single, containsPair('redirect_uri', q['redirect_uri'])); + expect(chatGptCodeChallenge(tokenForms.single['code_verifier']!), q['code_challenge']); + expect(auth.sharing, isTrue); + expect(auth.session!.email, 'me@example.com'); + + // Returning sign-in: the saved client id and host id, no name hint. + await auth.signIn((u) { + second = u; + return browser(u); + }); + expect(second.queryParameters['client_id'], 'oaiapp_1'); + expect(second.queryParameters['ext_agent_host_id'], q['ext_agent_host_id']); + expect(second.queryParameters.containsKey('agent_name_hint'), isFalse); + expect(second.queryParameters['id_token_hint'], isNotNull); + }); + + /// A token endpoint that holds the code exchange until [gate] opens, and + /// records every revocation. + ChatGptAuth pendingExchange(Completer gate, Completer arrived, + List<(String, String, String)> revoked, Uri Function() authorize, + {bool signed = true, int revokeStatus = 200}) => + ChatGptAuth(client: MockClient((req) async { + final oidc = _oidc(req); + if (oidc != null) return oidc; + if (req.url.path.endsWith('/revoke')) { + revoked.add((req.bodyFields['token']!, req.bodyFields['token_type_hint']!, + req.bodyFields['client_id']!)); + return http.Response('', revokeStatus); + } + arrived.complete(); + await gate.future; + var id = _idToken(req.bodyFields['client_id']!, authorize().queryParameters['nonce']!); + if (!signed) id = '${id.substring(0, id.lastIndexOf('.'))}.AAAA'; + return http.Response( + jsonEncode({ + 'access_token': 'at-1', + 'refresh_token': 'rt-1', + 'id_token': id, + 'scope': _fullScope, + }), + 200); + })); + + const issued = [ + ('rt-1', 'refresh_token', 'oaiapp_1'), + ('at-1', 'access_token', 'oaiapp_1'), + ]; + + for (final (what, end) in <(String, Future Function(ChatGptAuth))>[ + ('sign-out', (a) => a.signOut()), + ('cancel', (a) async => a.cancelSignIn()), + ('reset', (a) => a.forgetAll()), + ]) { + test('a code exchange that lands after a $what revokes what it was issued', () async { + final gate = Completer(), arrived = Completer(); + final revoked = <(String, String, String)>[]; + late Uri first; + auth = pendingExchange(gate, arrived, revoked, () => first); + final signIn = expectLater( + auth.signIn((u) { + first = u; + return browser(u, clientId: 'oaiapp_1'); + }), + throwsA(isA())); + await arrived.future; + // A reset waits for the exchange it interrupted, so it is not awaited + // before the exchange is let through. + final ended = end(auth); + gate.complete(); + await ended; + await signIn; + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + // The reset deleted the saved client id; the revocation still has it. + expect(revoked, issued); + }); + } + + for (final status in [200, 500]) { + test('a reset reports whether an interrupted code exchange revoked its tokens ' + '(revoke $status)', () async { + final gate = Completer(), arrived = Completer(); + final revoked = <(String, String, String)>[]; + late Uri first; + auth = pendingExchange(gate, arrived, revoked, () => first, revokeStatus: status); + final signIn = expectLater( + auth.signIn((u) { + first = u; + return browser(u, clientId: 'oaiapp_1'); + }), + throwsA(isA())); + await arrived.future; + final reset = auth.forgetAll(); + gate.complete(); + expect(await reset, status == 200); + await signIn; + expect(revoked, issued); + }); + } + + test('a sign-in cancelled before its listener is up never opens the browser', () async { + auth = ChatGptAuth(client: MockClient((_) async => fail('no network'))); + final signIn = auth.signIn((_) async => fail('the browser must not open')); + auth.cancelSignIn(); + await expectLater(signIn, throwsA(isA())); + }); + + test('a reset before the stored session has loaded still revokes it', () async { + SharedPreferences.setMockInitialValues( + {'coach_chatgpt_client_id': 'oaiapp_1', 'coach_chatgpt_present': true}); + keychain['coach_chatgpt_session'] = jsonEncode(_session().toJson()); + final revoked = []; + final auth = ChatGptAuth(client: MockClient((req) async { + revoked.add(req.bodyFields['token']!); + return http.Response('', 200); + })); + expect(auth.signedIn, isFalse, reason: 'load has not run'); + expect(await auth.forgetAll(), isTrue); + expect(revoked, ['rt-1', 'at-1']); + expect(keychain, isEmpty); + }); + + test('a reset that cannot read the stored session does not call it revoked', () async { + SharedPreferences.setMockInitialValues( + {'coach_chatgpt_client_id': 'oaiapp_1', 'coach_chatgpt_present': true}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'read') throw PlatformException(code: 'locked'); + return null; + }); + final auth = ChatGptAuth(client: MockClient((_) async => fail('nothing to revoke with'))); + expect(await auth.forgetAll(), isFalse); + }); + + test('a sign-in does not start while a reset is running', () async { + ResetGate.enter(); + addTearDown(ResetGate.resetForTest); + auth = ChatGptAuth(client: MockClient((_) async => fail('no network'))); + await expectLater( + auth.signIn((_) async => fail('no browser')), + throwsA(isA().having((e) => e.message, 'message', contains('reset')))); + }); + + test('a code exchange that lands while a reset is running is revoked, not stored', () async { + addTearDown(ResetGate.resetForTest); + final gate = Completer(), arrived = Completer(); + final revoked = <(String, String, String)>[]; + late Uri first; + auth = pendingExchange(gate, arrived, revoked, () => first); + final signIn = expectLater( + auth.signIn((u) { + first = u; + return browser(u, clientId: 'oaiapp_1'); + }), + throwsA(isA())); + await arrived.future; + ResetGate.enter(); // the reset has started but not reached forgetAll yet + gate.complete(); + await signIn; + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + expect(revoked, issued); + }); + + test('an ID token that fails its signature check is revoked, not stored', () async { + final gate = Completer()..complete(), arrived = Completer(); + final revoked = <(String, String, String)>[]; + late Uri first; + auth = pendingExchange(gate, arrived, revoked, () => first, signed: false); + await expectLater( + auth.signIn((u) { + first = u; + return browser(u, clientId: 'oaiapp_1'); + }), + throwsA(isA().having((e) => e.message, 'message', contains('check out')))); + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + expect(revoked, issued); + }); + + test('a sign-in already writing when sign-out starts is cleared afterwards', () async { + writeGate = Completer(); + final auth = ChatGptAuth(); + final store = expectLater(auth.debugSetSession(_session()), throwsA(isA())); + await pumpEventQueue(); + final out = auth.signOut(); + await pumpEventQueue(); + writeGate!.complete(); + await out; + await store; + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + expect((await SharedPreferences.getInstance()).getBool('coach_chatgpt_present'), isFalse); + }); + + test('a declined consent ends sign-in without a token exchange', () async { + auth = makeAuth(() => ''); + await expectLater( + auth.signIn((u) => browser(u, error: 'access_denied')), + throwsA(isA().having((e) => e.message, 'message', contains('cancelled'))), + ); + expect(tokenForms, isEmpty); + expect(auth.signedIn, isFalse); + }); + }); + + test('closing the browser can be cancelled instead of waiting it out', () async { + final auth = ChatGptAuth(client: MockClient((_) async => fail('no token exchange'))); + await expectLater( + auth.signIn((_) async { + auth.cancelSignIn(); // the user gave up while the page was up + return true; + }), + throwsA(isA().having((e) => e.message, 'message', contains('cancelled'))), + ); + }); + + test('ID token claims are checked', () { + final good = _idToken('oaiapp_1', 'n1'); + expect(chatGptIdentity(good, clientId: 'oaiapp_1', nonce: 'n1').sub, 'user-1'); + for (final (token, client, nonce) in [ + (good, 'oaiapp_1', 'other-nonce'), + (good, 'oaiapp_2', 'n1'), + (_idToken('oaiapp_1', 'n1', iss: 'https://evil.example'), 'oaiapp_1', 'n1'), + (_idToken('oaiapp_1', 'n1', exp: 1), 'oaiapp_1', 'n1'), + ('garbage', 'oaiapp_1', 'n1'), + ]) { + expect(() => chatGptIdentity(token, clientId: client, nonce: nonce), + throwsA(isA())); + } + }); + + group('ID token signature', () { + late int fetches; + late String servedKid; + late bool down; + late ChatGptJwks jwks; + setUp(() { + fetches = 0; + servedKid = _kid; + down = false; + jwks = ChatGptJwks( + issuer: kChatGptIssuer, + get: (u) async { + if (down) throw const SocketException('offline'); + if (u.path.endsWith('jwks.json')) fetches++; + return _oidc(http.Request('GET', u), kid: servedKid)!; + }, + ); + }); + + String claims() => _b64({'iss': kChatGptIssuer, 'sub': 'user-1'}); + + test('a token signed by a published key verifies, and the keys are cached', () async { + await jwks.verify(_idToken('oaiapp_1', 'n1')); + await jwks.verify(_idToken('oaiapp_1', 'n2')); + expect(fetches, 1); + }); + + test('a forged, re-signed or unsigned token is rejected', () async { + final good = _idToken('oaiapp_1', 'n1').split('.'); + final h = _b64({'alg': 'RS256', 'kid': _kid}); + for (final token in [ + '${good[0]}.${claims()}.${good[2]}', // claims swapped under the old signature + '${good[0]}.${good[1]}.${good[2].substring(0, good[2].length - 4)}AAAA', + '${_b64({'alg': 'none', 'kid': _kid})}.${good[1]}.', + '${_b64({'alg': 'HS256', 'kid': _kid})}.${good[1]}.${good[2]}', + '${_b64({'alg': 'RS256'})}.${good[1]}.${good[2]}', // no kid + '${_b64({'alg': 'RS256', 'kid': _kid, 'jwk': {'kty': 'RSA'}})}.${good[1]}.${good[2]}', + '$h.${good[1]}', + 'garbage', + ]) { + await expectLater(jwks.verify(token), throwsA(isA()), + reason: token); + } + }); + + test('an unknown kid refetches once, then is rejected', () async { + var now = DateTime(2026, 10, 1); + await withClock(Clock(() => now), () async { + await jwks.verify(_idToken('oaiapp_1', 'n1')); + expect(fetches, 1); + // A kid that is nowhere: rejected, and not a refetch per token. + for (var i = 0; i < 3; i++) { + await expectLater( + jwks.verify(_jwt({'iss': kChatGptIssuer}, header: {'alg': 'RS256', 'kid': 'nope'})), + throwsA(isA())); + } + expect(fetches, 1); + // Rotated: the token's kid is not cached yet, the new key set has it. + now = now.add(const Duration(minutes: 1)); + servedKid = 'rotated'; + await jwks.verify(_jwt({'iss': kChatGptIssuer}, header: {'alg': 'RS256', 'kid': 'rotated'})); + expect(fetches, 2); + }); + }); + + test('keys that cannot be fetched fail closed', () async { + down = true; + await expectLater( + jwks.verify(_idToken('oaiapp_1', 'n1')), throwsA(isA())); + }); + + test('short, non-RSA and encryption keys are not usable', () { + final n = _b64Int(_rsa.publicKey.modulus!), e = _b64Int(_rsa.publicKey.exponent!); + final keys = parseChatGptJwks(jsonEncode({ + 'keys': [ + {'kty': 'RSA', 'kid': 'ok', 'n': n, 'e': e}, + {'kty': 'RSA', 'kid': 'enc', 'use': 'enc', 'n': n, 'e': e}, + {'kty': 'RSA', 'kid': 'ps', 'alg': 'PS256', 'n': n, 'e': e}, + {'kty': 'RSA', 'kid': 'short', 'n': _b64Int(BigInt.two.pow(1023) + BigInt.one), 'e': e}, + {'kty': 'EC', 'kid': 'ec', 'x': 'AA', 'y': 'AA'}, + ], + })); + expect(keys.keys, ['ok']); + }); + }); + + group('refresh', () { + Future expired(http.Client c) async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final auth = ChatGptAuth(client: c); + await auth.debugSetSession(_session(expiresAt: DateTime.now())); + return auth; + } + + test('concurrent callers share one rotation', () async { + final forms = >[]; + final auth = await expired(MockClient((req) async { + forms.add(req.bodyFields); + return http.Response( + jsonEncode({'access_token': 'at-2', 'refresh_token': 'rt-2', 'expires_in': 3600}), 200); + })); + final tokens = await Future.wait([auth.accessToken(), auth.accessToken()]); + expect(tokens, ['at-2', 'at-2']); + expect(forms.single, { + 'grant_type': 'refresh_token', + 'client_id': 'oaiapp_1', + 'refresh_token': 'rt-1', + 'resource': kChatGptApiBase, + }); + expect(auth.session!.refreshToken, 'rt-2'); + expect(auth.sharing, isTrue, reason: 'scope carried over when not re-sent'); + }); + + test('a dead refresh token signs out; a server hiccup does not', () async { + final hiccup = await expired(MockClient((_) async => http.Response('oops', 502))); + await expectLater(hiccup.accessToken(), throwsA(isA())); + expect(hiccup.signedIn, isTrue); + + final dead = await expired( + MockClient((_) async => http.Response(jsonEncode({'error': 'invalid_grant'}), 400))); + await expectLater(dead.accessToken(), throwsA(isA())); + expect(dead.signedIn, isFalse); + }); + + test('a dead refresh token signs out even if the keychain delete fails', () async { + final dead = await expired( + MockClient((_) async => http.Response(jsonEncode({'error': 'invalid_grant'}), 400))); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'delete') throw PlatformException(code: 'boom', message: 'locked'); + fail('${call.method} must not run'); + }); + await expectLater(dead.accessToken(), throwsA(isA())); + expect(dead.signedIn, isFalse); + expect((await SharedPreferences.getInstance()).getBool('coach_chatgpt_present'), isFalse); + }); + }); + + group('coach requests on the ChatGPT plan', () { + late CoachConfig cfg; + setUp(() async { + cfg = CoachConfig(); + await cfg.chatgpt.debugSetSession(_session()); + await cfg.save(useChatGpt: true, model: 'gpt-6.1-sol'); + }); + + Future> post(http.Client c) => CoachEngine.postChat(cfg, { + 'model': 'gpt-6.1-sol', + 'messages': [ + {'role': 'system', 'content': 'be brief'}, + {'role': 'user', 'content': 'hi'}, + ], + 'temperature': 0.3, + }, client: c); + + test('streams to the public Responses API with the plan token', () async { + expect(cfg.configured, isTrue); + expect(cfg.isPrivateEndpoint, isFalse, reason: 'health data leaving the phone still asks first'); + late http.Request sent; + final reply = await post(MockClient((req) async { + sent = req; + return http.Response( + _sse([ + {'type': 'response.output_text.delta', 'delta': 'hel'}, + _completed('hello'), + ]), + 200, + ); + })); + expect(sent.url.toString(), '$kChatGptApiBase/responses'); + expect(sent.headers['Authorization'], 'Bearer at-1'); + final body = jsonDecode(sent.body) as Map; + expect(body['stream'], isTrue); + expect(body['store'], isFalse); + expect(body.containsKey('temperature'), isFalse); + expect([for (final i in body['input'] as List) i['role']], ['developer', 'user']); + expect(reply['content'], 'hello'); + }); + + test('output comes from output_item.done when completed.output is empty', () async { + Map done(int i, Map item) => + {'type': 'response.output_item.done', 'output_index': i, 'item': item}; + Map empty() => { + 'type': 'response.completed', + 'response': {'status': 'completed', 'output': []}, + }; + final message = (_completed('hello')['response'] as Map)['output'][0] as Map; + var reply = await post(MockClient( + (_) async => http.Response(_sse([done(0, message), empty()]), 200))); + expect(reply['content'], 'hello'); + reply = await post(MockClient((_) async => http.Response( + _sse([ + done(1, {'type': 'function_call', 'call_id': 'c1', 'name': 'f', 'arguments': '{}', 'status': 'completed'}), + done(0, message), + empty(), + ]), + 200))); + expect(reply['content'], 'hello'); + expect((reply['tool_calls'] as List).single['id'], 'c1'); + }); + + test('usage limit, mid-stream failure and a cut-off stream are errors, never a fallback', () async { + await cfg.save(apiKey: 'sk-test'); + for (final (status, body, expected) in [ + ( + 429, + jsonEncode({'error': {'code': 'subscription_sharing_usage_limit_exceeded'}}), + 'Usage limit reached' + ), + ( + 200, + _sse([ + { + 'type': 'response.failed', + 'response': {'error': {'code': 'subscription_sharing_usage_limit_exceeded'}}, + }, + ]), + 'Usage limit reached' + ), + (200, _sse([{'type': 'response.output_text.delta', 'delta': 'he'}]), 'ended early'), + ]) { + final sent = []; + await expectLater( + post(MockClient((req) async { + sent.add(req); + return http.Response(body, status); + })), + throwsA(isA().having((e) => e.message, 'message', contains(expected))), + ); + expect(sent, hasLength(1), reason: 'no fallback request'); + expect(sent.single.headers['Authorization'], 'Bearer at-1'); + } + }); + + test('function tools go in a namespace on this route, and history calls carry it', () async { + final tool = { + 'type': 'function', + 'function': { + 'name': 'run_sql', + 'description': 'query', + 'parameters': {'type': 'object', 'properties': {}}, + }, + }; + late Map sent; + final reply = await CoachEngine.postChat(cfg, { + 'model': 'gpt-6.1-sol', + 'messages': [ + {'role': 'user', 'content': 'hi'}, + { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + { + 'id': 'c0', + 'type': 'function', + 'function': {'name': 'run_sql', 'arguments': '{}'}, + }, + ], + }, + {'role': 'tool', 'tool_call_id': 'c0', 'content': '[]'}, + ], + 'tools': [tool], + 'tool_choice': 'auto', + }, client: MockClient((req) async { + sent = jsonDecode(req.body) as Map; + return http.Response( + _sse([ + { + 'type': 'response.completed', + 'response': { + 'status': 'completed', + 'output': [ + { + 'type': 'function_call', + 'name': 'run_sql', + 'namespace': kChatGptToolNamespace, + 'call_id': 'c1', + 'arguments': '{"sql":"SELECT 1"}', + 'status': 'completed', + }, + ], + }, + }, + ]), + 200); + })); + final tools = sent['tools'] as List; + expect(tools, hasLength(1)); + expect(tools.single['type'], 'namespace'); + expect(tools.single['name'], kChatGptToolNamespace); + expect((tools.single['tools'] as List).single, + allOf(containsPair('type', 'function'), containsPair('name', 'run_sql'))); + expect(sent['tool_choice'], 'auto'); + final call = (sent['input'] as List).firstWhere((i) => i['type'] == 'function_call'); + expect(call['namespace'], kChatGptToolNamespace); + expect(call['name'], 'run_sql'); + final out = (reply['tool_calls'] as List).single; + expect(out['function'], {'name': 'run_sql', 'arguments': '{"sql":"SELECT 1"}'}); + + // The API-key route still sends bare top-level function tools. + await cfg.save(useChatGpt: false, apiKey: 'sk-test'); + await CoachEngine.postChat(cfg, { + 'model': 'gpt-6.1-sol', + 'messages': [ + {'role': 'user', 'content': 'hi'}, + ], + 'tools': [tool], + }, client: MockClient((req) async { + sent = jsonDecode(req.body) as Map; + return http.Response(jsonEncode(_completed('ok')['response']), 200); + })); + expect((sent['tools'] as List).single['type'], 'function'); + }); + + test('signed in without plan usage is not configured', () async { + await cfg.chatgpt.debugSetSession(_session(scope: 'openid email profile offline_access')); + expect(cfg.configured, isFalse); + }); + + test('a leftover API key does not schedule reminders for a signed-out ChatGPT coach', () async { + await cfg.save(apiKey: 'sk-left-over'); + expect(cfg.remindersConfigured, isTrue); + await cfg.chatgpt.signOut(); + expect(cfg.hasKey, isTrue); + expect(cfg.remindersConfigured, isFalse); + await cfg.save(useChatGpt: false); + expect(cfg.remindersConfigured, isTrue, reason: 'the key route still counts its key'); + }); + }); + + group('session lifecycle', () { + test('sign-out during an in-flight refresh does not resurrect the session', () async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final gate = Completer(); + final revoked = []; + final auth = ChatGptAuth(client: MockClient((req) async { + if (req.url.path.endsWith('/revoke')) { + revoked.add(req.bodyFields['token']!); + return http.Response('', 200); + } + await gate.future; + return http.Response( + jsonEncode({'access_token': 'at-2', 'refresh_token': 'rt-2', 'expires_in': 3600}), 200); + })); + await auth.debugSetSession(_session(expiresAt: DateTime.now())); + final refresh = auth.accessToken(); + final refreshed = expectLater(refresh, throwsA(isA())); + await pumpEventQueue(); + final out = auth.signOut(); + await pumpEventQueue(); + gate.complete(); + expect(await out, isTrue); + await refreshed; + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + expect(revoked, ['rt-2', 'at-2'], reason: 'the rotated tokens are the live ones'); + }); + + test('a reset during a refresh does not make a later sign-out revoke the stale token', () async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final gate = Completer(); + final revoked = []; + final auth = ChatGptAuth(client: MockClient((req) async { + if (req.url.path.endsWith('/revoke')) { + revoked.add(req.bodyFields['token']!); + return http.Response('', 200); + } + await gate.future; + return http.Response( + jsonEncode({'access_token': 'at-2', 'refresh_token': 'rt-2', 'expires_in': 3600}), 200); + })); + await auth.debugSetSession(_session(expiresAt: DateTime.now())); + final refreshed = expectLater(auth.accessToken(), throwsA(isA())); + await pumpEventQueue(); + final reset = auth.forgetAll(); + gate.complete(); + await reset; + await refreshed; + // Signed in again, then out: the new session's token is the one revoked. + (await SharedPreferences.getInstance()).setString('coach_chatgpt_client_id', 'oaiapp_2'); + await auth.debugSetSession(ChatGptSession( + sub: 'user-1', + email: 'me@example.com', + idToken: 'i', + accessToken: 'at-new', + refreshToken: 'rt-new', + scope: _fullScope, + expiresAt: DateTime.now().add(const Duration(hours: 1)))); + await auth.signOut(); + // The reset revoked the rotated tokens; the sign-out revoked only its own. + expect(revoked, ['rt-2', 'at-2', 'rt-new', 'at-new']); + }); + + test('a refresh that lands after a new sign-in is revoked, not left for its sign-out', () async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final oldGate = Completer(); + final revoked = []; + final refreshed = []; + final auth = ChatGptAuth(client: MockClient((req) async { + if (req.url.path.endsWith('/revoke')) { + revoked.add(req.bodyFields['token']!); + return http.Response('', 200); + } + final rt = req.bodyFields['refresh_token']!; + refreshed.add(rt); + if (rt == 'rt-1') await oldGate.future; // the old session's refresh hangs + return http.Response( + jsonEncode({ + 'access_token': rt == 'rt-1' ? 'at-2' : 'at-new-2', + 'refresh_token': rt == 'rt-1' ? 'rt-2' : 'rt-new-2', + 'expires_in': 3600, + }), + 200); + })); + await auth.debugSetSession(_session(expiresAt: DateTime.now())); + final stale = expectLater(auth.accessToken(), throwsA(isA())); + await pumpEventQueue(); + // Signed out with the refresh still out (revocation unconfirmed until it + // lands), then signed straight back in. + final out = auth.signOut(); + await pumpEventQueue(); + await auth.debugSetSession(ChatGptSession( + sub: 'user-1', + email: 'me@example.com', + idToken: 'i', + accessToken: 'at-new', + refreshToken: 'rt-new', + scope: _fullScope, + expiresAt: DateTime.now())); + // The new session refreshes on its own token, not through the old call. + expect(await auth.accessToken(), 'at-new-2'); + oldGate.complete(); + await stale; + expect(await out, isTrue); + expect(revoked, ['rt-2', 'at-2'], reason: 'the old rotation is revoked as it lands'); + expect(auth.session!.refreshToken, 'rt-new-2'); + revoked.clear(); + await auth.signOut(); + expect(revoked, ['rt-new-2', 'at-new-2'], reason: "the sign-out revokes the current session's tokens"); + expect(refreshed, ['rt-1', 'rt-new']); + }); + + test('a sign-in during a slow sign-out survives it', () async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final gate = Completer(); + final auth = ChatGptAuth(client: MockClient((req) async { + await gate.future; // revocation still out + return http.Response('', 200); + })); + await auth.debugSetSession(_session()); + final out = auth.signOut(); + await pumpEventQueue(); + await auth.debugSetSession(_session(scope: '$_fullScope x')); // signed back in + gate.complete(); + await out; + expect(auth.signedIn, isTrue); + expect(keychain, isNotEmpty); + expect((await SharedPreferences.getInstance()).getBool('coach_chatgpt_present'), isTrue); + }); + + test('a non-duplicate keychain write failure leaves the stored session alone', () async { + final auth = ChatGptAuth(); + await auth.debugSetSession(_session()); + final stored = keychain.values.single; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'write') throw PlatformException(code: 'boom', message: 'disk full'); + fail('${call.method} must not run'); + }); + await expectLater( + auth.debugSetSession(ChatGptSession( + sub: 'user-1', + email: 'me@example.com', + idToken: 'i', + accessToken: 'at-9', + refreshToken: 'rt-9', + scope: _fullScope, + expiresAt: DateTime.now().add(const Duration(hours: 1)))), throwsA(isA())); + expect(auth.session!.accessToken, 'at-1'); + expect(keychain.values.single, stored); + }); + + for (final prefs in [{'coach_chatgpt_present': false}, {}]) { + test('load drops a keychain record the present marker does not vouch for ($prefs)', () async { + SharedPreferences.setMockInitialValues(prefs); + keychain['coach_chatgpt_session'] = jsonEncode(_session().toJson()); + final auth = ChatGptAuth(); + await auth.load(trusted: true); + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + }); + } + + for (final status in [200, 500]) { + test('forgetAll revokes, then clears the session, registration and welcome flag; ' + 'welcome shows once (revoke $status)', () async { + SharedPreferences.setMockInitialValues({ + 'coach_chatgpt_client_id': 'oaiapp_1', + 'coach_chatgpt_host_id': 'urn:uuid:h', + }); + final revoked = []; + final auth = ChatGptAuth(client: MockClient((req) async { + expect(req.url.path, endsWith('/revoke')); + expect(req.bodyFields['client_id'], 'oaiapp_1'); + revoked.add(req.bodyFields['token']!); + return http.Response('', status); + })); + await auth.debugSetSession(_session()); + expect(await auth.takeWelcome(), isTrue); + expect(await auth.takeWelcome(), isFalse); + expect(await auth.forgetAll(), status == 200, + reason: 'reset reports a revocation it could not confirm'); + final prefs = await SharedPreferences.getInstance(); + expect(revoked, ['rt-1', 'at-1']); + expect(auth.signedIn, isFalse); + expect(keychain, isEmpty); + expect(prefs.getKeys().where((k) => k.startsWith('coach_chatgpt_')), isEmpty); + expect(await auth.takeWelcome(), isTrue); + }); + } + + test('a refresh whose keychain write fails keeps the rotated token in memory', () async { + SharedPreferences.setMockInitialValues({'coach_chatgpt_client_id': 'oaiapp_1'}); + final sent = []; + var n = 0; + final auth = ChatGptAuth(client: MockClient((req) async { + sent.add(req.bodyFields['refresh_token']!); + n++; + return http.Response( + jsonEncode({'access_token': 'at-${n + 1}', 'refresh_token': 'rt-${n + 1}', 'expires_in': 1}), + 200); + })); + await auth.debugSetSession(_session(expiresAt: DateTime.now())); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'write') throw PlatformException(code: 'boom', message: 'disk full'); + fail('${call.method} must not run'); + }); + expect(await auth.accessToken(), 'at-2'); + expect(auth.session!.refreshToken, 'rt-2'); + // Still expiring (expires_in: 1), so the next call refreshes again. + expect(await auth.accessToken(), 'at-3'); + expect(sent, ['rt-1', 'rt-2'], reason: 'the spent token is never sent again'); + }); + }); + + test('model list keeps listed slugs in server order', () async { + final auth = ChatGptAuth( + client: MockClient((req) async { + expect(req.headers['Authorization'], 'Bearer at-1'); + return http.Response( + jsonEncode({ + 'models': [ + {'slug': 'gpt-6.1-sol', 'visibility': 'list'}, + {'slug': 'hidden', 'visibility': 'hide'}, + {'slug': 'gpt-5.6-terra', 'visibility': 'list'}, + ], + }), + 200, + ); + }), + ); + await auth.debugSetSession(_session()); + expect(await auth.listModels(), ['gpt-6.1-sol', 'gpt-5.6-terra']); + }); +} diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 15702d8a0..59ac92395 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -410,6 +410,8 @@ void main() { 400, scrollable: find.byType(Scrollable).first, ); + await t.ensureVisible(find.text(en.coachListModels)); + await t.pumpAndSettle(); await t.tap(find.text(en.coachListModels)); await t.pumpAndSettle(); const raw =