xworkmate-app/lib/runtime/account_runtime_client.dart

221 lines
6.4 KiB
Dart

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<Map<String, dynamic>> login({
required String identifier,
required String password,
}) {
return _requestJson(
method: 'POST',
path: '/api/auth/login',
body: <String, Object?>{
'identifier': identifier.trim(),
'password': password,
},
);
}
Future<Map<String, dynamic>> verifyMfa({
required String mfaToken,
required String code,
}) {
return _requestJson(
method: 'POST',
path: '/api/auth/mfa/verify',
body: <String, Object?>{
'mfaToken': mfaToken.trim(),
'method': 'totp',
'totpCode': code.trim(),
'code': code.trim(),
},
);
}
Future<AccountSessionSummary> loadSession({required String token}) async {
final payload = await loadProfile(token: token);
final user = _asMap(payload['user']);
return _accountSessionSummaryFromUserJson(user);
}
Future<Map<String, dynamic>> loadProfile({required String token}) {
return _requestJson(
method: 'GET',
path: '/api/auth/session',
bearerToken: token,
);
}
Future<Map<String, dynamic>> loadXWorkmateProfileSync({
required String token,
}) {
return _requestJson(
method: 'GET',
path: '/api/auth/xworkmate/profile/sync',
bearerToken: token,
);
}
Future<String> 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: <String, String>{
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<String, dynamic> 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: <String>['v1', mount, 'data', ...path]);
}
Future<Map<String, dynamic>> _requestJson({
required String method,
String path = '',
Uri? uriOverride,
String bearerToken = '',
Map<String, Object?>? body,
Map<String, String> rawHeaders = const <String, String>{},
}) 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 <String, dynamic>{}
: _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<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
static String _stringValue(Object? value) {
return value?.toString().trim() ?? '';
}
}