import 'dart:convert'; import 'dart:io'; import 'runtime_models.dart'; class AccountRuntimeException implements Exception { const AccountRuntimeException({ required this.statusCode, required this.errorCode, required this.message, }); final int statusCode; final String errorCode; final String message; @override String toString() { return 'AccountRuntimeException($statusCode, $errorCode, $message)'; } } class AccountRuntimeClient { AccountRuntimeClient({required String baseUrl}) : baseUrl = _normalizeBaseUrl(baseUrl); final String baseUrl; static String _normalizeBaseUrl(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { return ''; } return trimmed.endsWith('/') ? trimmed.substring(0, trimmed.length - 1) : trimmed; } Future> login({ required String identifier, required String password, }) { return _requestJson( method: 'POST', path: '/api/auth/login', body: { 'identifier': identifier.trim(), 'password': password, }, ); } Future> verifyMfa({ required String mfaToken, required String code, }) { return _requestJson( method: 'POST', path: '/api/auth/mfa/verify', body: { 'mfaToken': mfaToken.trim(), 'method': 'totp', 'totpCode': code.trim(), 'code': code.trim(), }, ); } Future loadSession({required String token}) async { final payload = await loadProfile(token: token); final user = _asMap(payload['user']); return _accountSessionSummaryFromUserJson(user); } Future> loadProfile({required String token}) { return _requestJson( method: 'GET', path: '/api/auth/session', bearerToken: token, ); } Future> loadXWorkmateProfileSync({ required String token, }) { return _requestJson( method: 'GET', path: '/api/auth/xworkmate/profile/sync', bearerToken: token, ); } Future readVaultSecretValue({ required String vaultUrl, required String namespace, required String vaultToken, required String secretPath, required String secretKey, }) async { final uri = _vaultReadUri(vaultUrl, secretPath); final payload = await _requestJson( method: 'GET', uriOverride: uri, rawHeaders: { if (namespace.trim().isNotEmpty) 'X-Vault-Namespace': namespace.trim(), if (vaultToken.trim().isNotEmpty) 'X-Vault-Token': vaultToken.trim(), }, ); final data = _asMap(payload['data']); final secretData = _asMap(data['data']); return _stringValue(secretData[secretKey]); } AccountSessionSummary _accountSessionSummaryFromUserJson( Map user, ) { final mfa = _asMap(user['mfa']); final totpEnabled = mfa['totpEnabled'] as bool? ?? false; final totpPending = mfa['totpPending'] as bool? ?? false; return AccountSessionSummary( userId: _stringValue(user['id']), email: _stringValue(user['email']), name: _stringValue(user['name']).isNotEmpty ? _stringValue(user['name']) : _stringValue(user['username']), role: _stringValue(user['role']), mfaEnabled: user['mfaEnabled'] as bool? ?? totpEnabled, totpEnabled: totpEnabled, totpPending: totpPending, ); } Uri _vaultReadUri(String rawBaseUrl, String secretPath) { final base = Uri.parse(_normalizeBaseUrl(rawBaseUrl)); final trimmedPath = secretPath.trim().replaceAll(RegExp(r'^/+|/+$'), ''); final segments = trimmedPath .split('/') .map((item) => item.trim()) .where((item) => item.isNotEmpty) .toList(growable: false); if (segments.length < 2) { throw const AccountRuntimeException( statusCode: 400, errorCode: 'invalid_vault_path', message: 'invalid vault path', ); } final mount = segments.first; final path = segments.skip(1).toList(growable: false); return base.replace(pathSegments: ['v1', mount, 'data', ...path]); } Future> _requestJson({ required String method, String path = '', Uri? uriOverride, String bearerToken = '', Map? body, Map rawHeaders = const {}, }) async { final uri = uriOverride ?? Uri.parse('$baseUrl$path'); final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); try { final request = await switch (method.toUpperCase()) { 'POST' => client.postUrl(uri), 'GET' => client.getUrl(uri), _ => throw UnsupportedError('Unsupported method $method'), }; request.headers.set(HttpHeaders.acceptHeader, 'application/json'); if (bearerToken.trim().isNotEmpty) { request.headers.set( HttpHeaders.authorizationHeader, 'Bearer ${bearerToken.trim()}', ); } for (final entry in rawHeaders.entries) { request.headers.set(entry.key, entry.value); } if (body != null) { request.headers.contentType = ContentType.json; request.write(jsonEncode(body)); } final response = await request.close().timeout( const Duration(seconds: 6), ); final rawBody = await utf8.decoder.bind(response).join(); final decoded = rawBody.trim().isEmpty ? const {} : _asMap(jsonDecode(rawBody)); if (response.statusCode < 200 || response.statusCode >= 300) { throw AccountRuntimeException( statusCode: response.statusCode, errorCode: _stringValue(decoded['error']).isNotEmpty ? _stringValue(decoded['error']) : 'request_failed', message: _stringValue(decoded['message']).isNotEmpty ? _stringValue(decoded['message']) : rawBody.trim(), ); } return decoded; } finally { client.close(force: true); } } static Map _asMap(Object? value) { if (value is Map) { return value; } if (value is Map) { return value.cast(); } return const {}; } static String _stringValue(Object? value) { return value?.toString().trim() ?? ''; } }