utils/crdt_sync_dart/lib/src/firebase_config.dart
Krzysztof kuhy Rudnicki 027e67b197 crdt_sync: SYNC_NAMESPACE sandbox define
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:48:02 +01:00

189 lines
6.6 KiB
Dart

/// The Dart counterpart to Python's `crdt_sync._config`.
///
/// Both languages talk to one Firebase project as one account, so the shape
/// of the configuration is shared even though where it is *stored* is not:
/// on Linux it is `~/.config/crdt-sync/firebase.json` (mode 0600), while on
/// Android the password and refresh token belong in `flutter_secure_storage`
/// and the file does not exist at all.
///
/// [FirebaseConfig] therefore takes plain values and stays free of `dart:io`,
/// so it works on web and mobile. The platform-specific loading is the app's
/// job -- `crdt_sync_io.dart` provides the desktop half.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'firebase_auth_rest.dart';
import 'firebase_credentials.dart';
import 'firebase_client.dart';
import 'mirror_store.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
/// The shared config is missing a field, or still holds a scaffold placeholder.
class ConfigException implements Exception {
ConfigException(this.message);
final String message;
@override
String toString() => 'ConfigException: $message';
}
/// Everything needed to reach the shared Firebase project.
///
/// [apiKey] is not a secret: it ships inside the Android APKs, and the
/// security rules are what protect the data. The password is.
class FirebaseConfig {
const FirebaseConfig({
required this.apiKey,
required this.databaseUrl,
required this.projectId,
required this.uid,
required this.email,
});
/// Parses the same `firebase.json` the Python side reads.
///
/// Throws [ConfigException] naming the field at fault, because the
/// alternative is an authentication failure much later with no clue which
/// value was wrong. Keys prefixed `_comment` are the scaffold's inline
/// documentation, not configuration, and are ignored.
factory FirebaseConfig.fromJson(Map<String, dynamic> json) {
String require(String key) {
final value = json[key];
if (value == null || (value is String && value.trim().isEmpty)) {
throw ConfigException('firebase.json is missing or has empty: $key');
}
final text = value.toString();
if (text.contains('PASTE_')) {
throw ConfigException(
'firebase.json still holds the placeholder for: $key',
);
}
return text;
}
return FirebaseConfig(
apiKey: require('apiKey'),
databaseUrl: require('databaseUrl'),
projectId: require('projectId'),
uid: require('uid'),
email: require('email'),
);
}
/// Parses [text] as the shared config file's contents.
factory FirebaseConfig.parse(String text) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException catch (error) {
throw ConfigException(
'firebase.json is not valid JSON: ${error.message}',
);
}
if (decoded is! Map<String, dynamic>) {
throw ConfigException('firebase.json must contain a JSON object');
}
return FirebaseConfig.fromJson(decoded);
}
final String apiKey;
final String databaseUrl;
final String projectId;
/// The uid the security rules pin. A session for any other uid authenticates
/// fine and is then denied every read and write.
final String uid;
final String email;
}
/// Returns a client for [config], signing in only when there is no session.
///
/// The common path costs no authentication round trip: [store] holds the
/// refresh token between launches. Pass the platform-appropriate store --
/// `flutter_secure_storage` on Android, a 0600 file on desktop.
/// Supplies a Google ID token for the account to sign in as, or null when the
/// user is not signed in with Google on this device.
///
/// A closure rather than a dependency: `google_sign_in` is Android/iOS/web
/// only, and this package must keep working on Linux desktop and headless
/// under systemd. The app owns the plugin; the library only wants the token.
typedef GoogleIdTokenProvider = Future<String?> Function();
Future<FirebaseRestClient> firebaseClientFor({
required FirebaseConfig config,
required FirebaseCredentialStore store,
String? password,
GoogleIdTokenProvider? googleIdToken,
String? expectedUid,
http.Client? httpClient,
}) async {
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
checkSyncNamespace(syncNamespace);
final auth = FirebaseTokenProvider(
apiKey: config.apiKey,
store: store,
httpClient: httpClient,
);
if (await store.load() == null) {
// Google first: it is the one-tap path, and on a fresh install it is the
// only credential the user has not had to type. Password stays as the
// fallback, and is the only option on desktop and headless.
final google = await googleIdToken?.call();
if (google != null) {
// config.uid is empty on the app path (FirebaseProject.configFor leaves
// it blank), in which case signInWithGoogle skips the comparison rather
// than failing every sign-in against ''. Apps that know their uid should
// pass expectedUid so a mis-tapped account picker is caught here instead
// of turning into a silently denied sync.
await auth.signInWithGoogle(
idToken: google,
expectedUid: expectedUid ?? config.uid,
);
} else if (password != null) {
await auth.signIn(email: config.email, password: password);
} else {
throw ConfigException(
'no stored session for ${config.email}, no Google sign-in and no '
'password given; sign in once from the settings screen',
);
}
}
return FirebaseRestClient(
databaseUrl: config.databaseUrl,
auth: auth,
httpClient: httpClient,
);
}
/// Returns a Firebase-primary store that still mirrors to [githubClient].
///
/// What an app uses *during* the cutover: Firebase is authoritative, GitHub is
/// kept in step but never allowed to fail a tick, and reads union both so
/// devices can cut over one at a time. Rolling back is passing [githubClient]
/// straight to `syncLog` again; retiring the mirror is calling
/// [firebaseClientFor] instead. Both are one-line changes at the call site.
Future<MirrorStore> mirrorStoreFor({
required FirebaseConfig config,
required FirebaseCredentialStore store,
required RemoteStore githubClient,
String? password,
GoogleIdTokenProvider? googleIdToken,
String? expectedUid,
http.Client? httpClient,
}) async => MirrorStore(
primary: await firebaseClientFor(
config: config,
store: store,
password: password,
googleIdToken: googleIdToken,
expectedUid: expectedUid,
httpClient: httpClient,
),
mirror: githubClient,
);