import 'dart:io'; import 'package:path/path.dart' as p; /// Operator-managed gRPC bearer token used to authenticate /// Studio against a hub that has `auth.tokens:` configured /// (RBAC Level 2). Stored at `~/.fai/hub-auth-token` (mode /// 0600 on Unix). Sister of [RegistryToken] — same on-disk /// hygiene, different secret. /// /// When the file is present and non-empty Studio constructs /// `HubClient(authToken: ...)`, which attaches /// `Authorization: Bearer ` to every gRPC and /// gRPC-Web call. When the file is missing Studio stays in /// anonymous mode; the hub then either accepts or rejects /// based on its own `auth.tokens:` config. class HubAuthToken { static String _faiHome() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { throw StateError( 'Cannot resolve home directory (no HOME / USERPROFILE)', ); } return p.join(home, '.fai'); } /// Absolute path to the token file. static String get path => p.join(_faiHome(), 'hub-auth-token'); /// True iff the file exists with a non-empty trimmed body. static Future isConfigured() async { final f = File(path); if (!await f.exists()) return false; final content = await f.readAsString(); return content.trim().isNotEmpty; } /// Length of the trimmed token, or null when the file is /// missing / empty. Used for status display ("Configured /// (40 chars)") without ever surfacing the secret. static Future charCount() async { final f = File(path); if (!await f.exists()) return null; final content = await f.readAsString(); final trimmed = content.trim(); return trimmed.isEmpty ? null : trimmed.length; } /// Read the trimmed token, or `null` when missing/empty. /// Studio calls this at startup so [HubService] can supply /// it to the underlying gRPC client. static Future read() async { final f = File(path); if (!await f.exists()) return null; final content = (await f.readAsString()).trim(); return content.isEmpty ? null : content; } /// Persist [token] to disk, creating `~/.fai/` if needed. /// On Unix the file is chmod-ed to 0600. Windows leaves the /// default user-ACL alone. static Future write(String token) async { final f = File(path); await f.parent.create(recursive: true); await f.writeAsString(token.trim(), flush: true); if (Platform.isLinux || Platform.isMacOS) { try { await Process.run('chmod', ['600', f.path]); } catch (_) { // best-effort; default perms still confine to the // operator's machine. } } } /// Remove the token file. Returns true iff the file existed /// before deletion. static Future clear() async { final f = File(path); if (!await f.exists()) return false; await f.delete(); return true; } }