diff --git a/crdt_sync_dart/lib/crdt_sync.dart b/crdt_sync_dart/lib/crdt_sync.dart index 0c17979..f6da1da 100644 --- a/crdt_sync_dart/lib/crdt_sync.dart +++ b/crdt_sync_dart/lib/crdt_sync.dart @@ -2,6 +2,7 @@ library; export 'src/device.dart'; +export 'src/firebase_account_provisioning.dart'; export 'src/firebase_auth_rest.dart'; export 'src/firebase_client.dart'; export 'src/firebase_config.dart'; diff --git a/crdt_sync_dart/lib/src/firebase_account_provisioning.dart b/crdt_sync_dart/lib/src/firebase_account_provisioning.dart new file mode 100644 index 0000000..d699eeb --- /dev/null +++ b/crdt_sync_dart/lib/src/firebase_account_provisioning.dart @@ -0,0 +1,62 @@ +/// Provisioning the sync account on a desktop (Chrome web) build. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'firebase_settings.dart'; + +/// The wrapper route that serves this machine's shared sync account. +/// +/// Desktop builds of these apps are Flutter **web** running in a Chrome +/// `--app` window, so `FlutterSecureStorage` resolves to the web backend: +/// AES-encrypted `localStorage`, keyed inside the browser profile. Nothing +/// outside that profile can write it -- not `secret-tool` (wrong store), not +/// a Dart CLI (`flutter_secure_storage` is a Flutter plugin and will not even +/// import on the VM). The only process that can both read the filesystem and +/// reach the page is the wrapper that serves it, so the account arrives the +/// same way the GitHub token already does: from the wrapper, never from +/// source and never from prefs. +const kSyncAccountPath = '/sync-account'; + +/// Env var a wrapper checks before serving [kSyncAccountPath]. +/// +/// Off by default. The route hands out a credential with database write +/// access to anything that can reach the wrapper's port, so it is opt-in per +/// launch rather than always-on: set it once to provision a new desktop +/// install, then launch normally. +const kSyncAccountEnvVar = 'CRDT_SYNC_SERVE_ACCOUNT'; + +/// Marker written when the user disconnects, suppressing re-provisioning. +/// +/// Without it, "Disconnect" would delete the stored account and the next +/// launch would silently adopt it again from the wrapper, making the button +/// look broken. +const kSyncAccountOptOutKey = 'firebase.account.optOut'; + +/// Fetches the shared account the wrapper holds, or null. +/// +/// Returns null for every failure -- route absent (the normal case, since it +/// is opt-in), wrapper not running, malformed body -- because the caller's +/// fallback is simply "not configured", which the settings screen already +/// handles by asking the user. +/// +/// Args: +/// base: Origin of the local wrapper, e.g. `http://localhost:8730`. +/// client: Injected for tests. +Future accountFromWrapper( + Uri base, { + http.Client? client, +}) async { + final httpClient = client ?? http.Client(); + try { + final response = await httpClient.get(base.resolve(kSyncAccountPath)); + if (response.statusCode != 200) return null; + return FirebaseAccount.tryParse(utf8.decode(response.bodyBytes)); + } on Exception { + return null; + } finally { + if (client == null) httpClient.close(); + } +} diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index c7225f0..3c7bc54 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -2,7 +2,7 @@ name: crdt_sync description: >- Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps. Pure Dart, no Flutter dependency. -version: 0.7.0 +version: 0.8.0 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/firebase_account_provisioning_test.dart b/crdt_sync_dart/test/firebase_account_provisioning_test.dart new file mode 100644 index 0000000..e8ee4ff --- /dev/null +++ b/crdt_sync_dart/test/firebase_account_provisioning_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:test/test.dart'; + +final _base = Uri.parse('http://localhost:8730'); + +void main() { + group('accountFromWrapper', () { + test('parses the account the wrapper serves', () async { + final client = http_testing.MockClient( + (request) async => http.Response( + jsonEncode({'email': 'a@b.c', 'password': 'pw'}), + 200, + ), + ); + + final account = await accountFromWrapper(_base, client: client); + + expect(account?.email, 'a@b.c'); + expect(account?.password, 'pw'); + }); + + test('requests the shared route path', () async { + var requested = ''; + final client = http_testing.MockClient((request) async { + requested = request.url.path; + return http.Response('{}', 404); + }); + + await accountFromWrapper(_base, client: client); + + expect(requested, kSyncAccountPath); + }); + + test('a missing route is not configured, not an error', () async { + // The normal case: the route is opt-in, so 404 is what most launches + // see and it must degrade to "ask the user" rather than throw. + final client = http_testing.MockClient( + (request) async => http.Response('', 404), + ); + + expect(await accountFromWrapper(_base, client: client), isNull); + }); + + test('a malformed body yields null rather than throwing', () async { + final client = http_testing.MockClient( + (request) async => http.Response('{not json', 200), + ); + + expect(await accountFromWrapper(_base, client: client), isNull); + }); + + test('a body missing the password yields null', () async { + final client = http_testing.MockClient( + (request) async => http.Response(jsonEncode({'email': 'a@b.c'}), 200), + ); + + expect(await accountFromWrapper(_base, client: client), isNull); + }); + + test('a wrapper that is not running yields null', () async { + final client = http_testing.MockClient( + (request) async => throw http.ClientException('refused'), + ); + + expect(await accountFromWrapper(_base, client: client), isNull); + }); + }); +}