+signup +login
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed

This commit is contained in:
Stephan D
2025-11-17 20:16:45 +01:00
parent 1ab7f2e7d3
commit c6a56071b5
89 changed files with 1308 additions and 3497 deletions

View File

@@ -4,7 +4,10 @@ import 'package:share_plus/share_plus.dart';
import 'package:pshared/api/requests/signup.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/requests/change_password.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/requests/password/change.dart';
import 'package:pshared/api/requests/password/forgot.dart';
import 'package:pshared/api/requests/password/reset.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/service/authorization/service.dart';
@@ -17,9 +20,9 @@ class AccountService {
static final _logger = Logger('service.account');
static const String _objectType = Services.account;
static Future<Account> login(String email, String password, String locale) async {
static Future<Account> login(LoginData login) async {
_logger.fine('Logging in');
return AuthorizationService.login(_objectType, email, password, locale);
return AuthorizationService.login(_objectType, login);
}
static Future<Account> restore() async {
@@ -27,6 +30,7 @@ class AccountService {
}
static Future<void> signup(SignupRequest request) async {
// Use regular HTTP for public signup endpoint (no auth needed)
await getPOSTResponse(_objectType, 'signup', request.toJson());
}
@@ -42,9 +46,20 @@ class AccountService {
static Future<Account> update(Account account) async {
_logger.fine('Patching account ${account.id}');
// Use AuthorizationService for authenticated operations
return _getAccount(AuthorizationService.getPUTResponse(_objectType, '', account.toDTO().toJson()));
}
static Future<void> forgotPassword(String email) async {
_logger.fine('Requesting password reset for email: $email');
await getPUTResponse(_objectType, 'password', ForgotPasswordRequest.build(login: email).toJson());
}
static Future<void> resetPassword(String accountRef, String token, String newPassword) async {
_logger.fine('Resetting password for account: $accountRef');
await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson());
}
static Future<Account> changePassword(String oldPassword, String newPassword) async {
_logger.fine('Changing password');
return _getAccount(AuthorizationService.getPATCHResponse(

View File

@@ -0,0 +1,92 @@
import 'package:logging/logging.dart';
/// Circuit breaker pattern implementation for authentication service failures
class AuthCircuitBreaker {
static final _logger = Logger('service.auth_circuit_breaker');
static int _failureCount = 0;
static DateTime? _lastFailure;
static const int _failureThreshold = 3;
static const Duration _recoveryTime = Duration(minutes: 5);
/// Returns true if the circuit breaker is open (blocking operations)
static bool get isOpen {
if (_failureCount < _failureThreshold) return false;
if (_lastFailure == null) return false;
final isOpen = DateTime.now().difference(_lastFailure!) < _recoveryTime;
if (isOpen) {
_logger.warning('Circuit breaker is OPEN. Failure count: $_failureCount, last failure: $_lastFailure');
}
return isOpen;
}
/// Returns true if the circuit breaker is in half-open state (allowing test requests)
static bool get isHalfOpen {
if (_failureCount < _failureThreshold) return false;
if (_lastFailure == null) return false;
return DateTime.now().difference(_lastFailure!) >= _recoveryTime;
}
/// Executes an operation with circuit breaker protection
static Future<T> execute<T>(Future<T> Function() operation) async {
if (isOpen) {
final timeSinceFailure = _lastFailure != null
? DateTime.now().difference(_lastFailure!)
: Duration.zero;
final timeUntilRecovery = _recoveryTime - timeSinceFailure;
_logger.warning('Circuit breaker blocking operation. Recovery in: ${timeUntilRecovery.inSeconds}s');
throw Exception('Auth service temporarily unavailable. Try again in ${timeUntilRecovery.inMinutes} minutes.');
}
try {
_logger.fine('Executing operation through circuit breaker');
final result = await operation();
_reset();
return result;
} catch (e) {
_recordFailure();
rethrow;
}
}
/// Records a failure and updates the circuit breaker state
static void _recordFailure() {
_failureCount++;
_lastFailure = DateTime.now();
_logger.warning('Auth circuit breaker recorded failure #$_failureCount at $_lastFailure');
if (_failureCount >= _failureThreshold) {
_logger.severe('Auth circuit breaker OPENED after $_failureCount failures');
}
}
/// Resets the circuit breaker to closed state
static void _reset() {
if (_failureCount > 0) {
_logger.info('Auth circuit breaker CLOSED - resetting failure count from $_failureCount to 0');
}
_failureCount = 0;
_lastFailure = null;
}
/// Manual reset (for testing or administrative purposes)
static void manualReset() {
_logger.info('Auth circuit breaker manually reset');
_reset();
}
/// Get current status for debugging
static Map<String, dynamic> getStatus() {
return {
'failureCount': _failureCount,
'lastFailure': _lastFailure?.toIso8601String(),
'isOpen': isOpen,
'isHalfOpen': isHalfOpen,
'threshold': _failureThreshold,
'recoveryTime': _recoveryTime.inSeconds,
};
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:math';
import 'package:logging/logging.dart';
import 'package:pshared/utils/exception.dart';
class RetryHelper {
static final _logger = Logger('auth.retry');
/// Executes an operation with exponential backoff retry logic
static Future<T> withExponentialBackoff<T>(
Future<T> Function() operation, {
int maxRetries = 3,
Duration initialDelay = const Duration(milliseconds: 500),
double backoffMultiplier = 2.0,
Duration maxDelay = const Duration(seconds: 30),
bool Function(Exception)? shouldRetry,
}) async {
Exception? lastException;
// Total attempts = initial attempt + maxRetries
final totalAttempts = maxRetries + 1;
for (int attempt = 1; attempt <= totalAttempts; attempt++) {
try {
_logger.fine('Attempting operation (attempt $attempt/$totalAttempts)');
return await operation();
} catch (e) {
lastException = toException(e);
// Don't retry if we've reached max attempts
if (attempt == totalAttempts) {
_logger.warning('Operation failed after $totalAttempts attempts: $lastException');
rethrow;
}
// Check if we should retry this specific error
if (shouldRetry != null && !shouldRetry(lastException)) {
_logger.fine('Operation failed with non-retryable error: $lastException');
rethrow;
}
// Calculate delay with exponential backoff
final delayMs = min(
initialDelay.inMilliseconds * pow(backoffMultiplier, attempt - 1).toInt(),
maxDelay.inMilliseconds,
);
final delay = Duration(milliseconds: delayMs);
_logger.fine('Operation failed (attempt $attempt), retrying in ${delay.inMilliseconds}ms: $lastException');
await Future.delayed(delay);
}
}
// This should never be reached due to rethrow above, but just in case
throw lastException ?? Exception('Retry logic error');
}
/// Determines if an error is retryable (network/temporary errors)
static bool isRetryableError(Exception error) {
final errorString = error.toString().toLowerCase();
// Network connectivity issues
if (errorString.contains('socket') ||
errorString.contains('connection') ||
errorString.contains('timeout') ||
errorString.contains('network')) {
return true;
}
// Server temporary errors (5xx)
if (errorString.contains('500') ||
errorString.contains('502') ||
errorString.contains('503') ||
errorString.contains('504')) {
return true;
}
// Rate limiting
if (errorString.contains('429')) {
return true;
}
return false;
}
}

View File

@@ -1,20 +1,38 @@
import 'package:logging/logging.dart';
import 'package:pshared/api/errors/upload_failed.dart';
import 'package:pshared/api/errors/authorization_failed.dart';
import 'package:pshared/api/requests/login.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/service/authorization/circuit_breaker.dart';
import 'package:pshared/service/authorization/retry_helper.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/authorization/token.dart';
import 'package:pshared/service/device_id.dart';
import 'package:pshared/utils/exception.dart';
import 'package:pshared/utils/http/requests.dart' as httpr;
/// AuthorizationService provides centralized authorization management
/// with token refresh, retry logic, and circuit breaker patterns
class AuthorizationService {
static final _logger = Logger('service.authorization');
static final _logger = Logger('service.authorization.auth_service');
static Future<Account> login(String service, LoginData login) async {
_logger.fine('Logging in ${login.login} with ${login.locale} locale');
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
service,
'/login',
LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(),
);
return (await _completeLogin(response)).account.toDomain();
}
static Future<void> _updateAccessToken(AccountResponse response) async {
await AuthorizationStorage.updateToken(response.accessToken);
@@ -31,59 +49,86 @@ class AuthorizationService {
return lr;
}
static Future<Account> login(String service, String email, String password, String locale) async {
_logger.fine('Logging in $email with $locale locale');
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
service,
'/login',
LoginRequest(
login: email.toLowerCase(),
password: password,
locale: locale,
deviceId: deviceId,
clientId: Constants.clientId,
).toJson());
return (await _completeLogin(response)).account.toDomain();
}
static Future<Account> restore() async {
return (await TokenService.rotateRefreshToken()).account.toDomain();
return (await TokenService.refreshAccessToken()).account.toDomain();
}
static Future<void> logout() async {
return AuthorizationStorage.removeTokens();
}
static Future<Map<String, dynamic>> _authenticatedRequest(
String service,
String url,
Future<Map<String, dynamic>> Function(String, String, Map<String, dynamic>, {String? authToken}) requestType,
{Map<String, dynamic>? body}) async {
final accessToken = await TokenService.getAccessToken();
return requestType(service, url, body ?? {}, authToken: accessToken);
}
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPOSTResponse, body: body);
// Original AuthorizationService methods - keeping the interface unchanged
static Future<Map<String, dynamic>> getGETResponse(String service, String url) async {
final accessToken = await TokenService.getAccessToken();
return httpr.getGETResponse(service, url, authToken: accessToken);
final token = await TokenService.getAccessTokenSafe();
return httpr.getGETResponse(service, url, authToken: token);
}
static Future<Map<String, dynamic>> getPUTResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPUTResponse, body: body);
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPOSTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getPATCHResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPATCHResponse, body: body);
static Future<Map<String, dynamic>> getPUTResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPUTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getDELETEResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getDELETEResponse, body: body);
static Future<Map<String, dynamic>> getPATCHResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPATCHResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getDELETEResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getDELETEResponse(service, url, body, authToken: token);
}
static Future<String> getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List<int> bytes) async {
final accessToken = await TokenService.getAccessToken();
final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: accessToken);
final token = await TokenService.getAccessTokenSafe();
final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: token);
if (res == null) {
throw ErrorUploadFailed();
throw Exception('Upload failed');
}
return res.url;
}
static Future<bool> isAuthorizationStored() async => AuthorizationStorage.isAuthorizationStored();
/// Execute an operation with automatic token management and retry logic
static Future<T> executeWithAuth<T>(
Future<T> Function() operation,
String description, {
int? maxRetries,
}) async => AuthCircuitBreaker.execute(() async => RetryHelper.withExponentialBackoff(
operation,
maxRetries: maxRetries ?? 3,
initialDelay: Duration(milliseconds: 100),
maxDelay: Duration(seconds: 5),
shouldRetry: (error) => RetryHelper.isRetryableError(error),
));
/// Handle 401 unauthorized errors with automatic token recovery
static Future<T> handleUnauthorized<T>(
Future<T> Function() operation,
String description,
) async {
_logger.warning('Handling unauthorized error with token recovery: $description');
return executeWithAuth(
() async {
try {
// Attempt token recovery first
await TokenService.handleUnauthorized();
// Retry the original operation
return await operation();
} catch (e) {
_logger.severe('Token recovery failed', e);
throw AuthenticationFailedException('Token recovery failed', toException(e));
}
},
'unauthorized recovery: $description',
);
}
}

View File

@@ -20,6 +20,34 @@ class AuthorizationStorage {
return TokenData.fromJson(jsonDecode(tokenJson));
}
static Future<bool> _checkTokenUsable(String keyName) async {
final hasKey = await SecureStorageService.containsKey(keyName);
if (!hasKey) return false;
try {
final tokenData = await _getTokenData(keyName);
return tokenData.expiration.isAfter(DateTime.now());
} catch (e, st) {
_logger.warning('Error reading token from $keyName: $e', e, st);
rethrow;
}
}
static Future<bool> isAuthorizationStored() async {
_logger.fine('Checking if authorization is stored');
final accessUsable = await _checkTokenUsable(Constants.accessTokenStorageKey);
if (accessUsable) return true;
final refreshUsable = await _checkTokenUsable(Constants.refreshTokenStorageKey);
if (refreshUsable) return true;
return false;
}
static Future<TokenData> getAccessToken() async {
_logger.fine('Getting access token');
return _getTokenData(Constants.accessTokenStorageKey);

View File

@@ -1,20 +1,25 @@
import 'package:logging/logging.dart';
import 'package:pshared/api/errors/authorization_failed.dart';
import 'package:pshared/api/errors/unauthorized.dart';
import 'package:pshared/api/requests/tokens/access_refresh.dart';
import 'package:pshared/api/requests/tokens/refresh_rotate.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/error/server.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/service/authorization/circuit_breaker.dart';
import 'package:pshared/service/authorization/retry_helper.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/authorization/token_mutex.dart';
import 'package:pshared/service/device_id.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/exception.dart';
import 'package:pshared/utils/http/requests.dart';
class TokenService {
static final _logger = Logger('service.authorization.token');
static const String _objectType = Services.account;
@@ -26,7 +31,11 @@ class TokenService {
static Future<String> getAccessToken() async {
TokenData token = await AuthorizationStorage.getAccessToken();
if (_isTokenExpiringSoon(token, const Duration(hours: 4))) {
token = (await _refreshAccessToken()).accessToken;
// Use mutex to prevent concurrent refresh operations
final refreshedToken = await TokenRefreshMutex().executeRefresh(() async {
return (await refreshAccessToken()).accessToken.token;
});
return refreshedToken;
}
return token.token;
}
@@ -36,13 +45,13 @@ class TokenService {
await AuthorizationStorage.updateRefreshToken(response.refreshToken);
}
static Future<AccountResponse> _refreshAccessToken() async {
static Future<AccountResponse> refreshAccessToken() async {
_logger.fine('Refreshing access token...');
final deviceId = await DeviceIdManager.getDeviceId();
final refresh = await AuthorizationStorage.getRefreshToken();
if (_isTokenExpiringSoon(refresh, const Duration(days: 7))) {
return await rotateRefreshToken();
return await _rotateRefreshToken();
}
final response = await getPOSTResponse(
@@ -60,7 +69,7 @@ class TokenService {
return accountResp;
}
static Future<LoginResponse> rotateRefreshToken() async {
static Future<LoginResponse> _rotateRefreshToken() async {
_logger.fine('Rotating refresh token...');
final refresh = await AuthorizationStorage.getRefreshToken();
@@ -82,4 +91,89 @@ class TokenService {
return loginResponse;
}
/// Enhanced method to handle unexpected 401 errors with fallback logic
static Future<void> handleUnauthorized() async {
_logger.warning('Handling unexpected 401 unauthorized error');
return AuthCircuitBreaker.execute(() async {
return RetryHelper.withExponentialBackoff(
() async {
try {
// Try refresh first (faster)
final currentRefresh = await AuthorizationStorage.getRefreshToken();
if (!_isTokenExpiringSoon(currentRefresh, const Duration(days: 1))) {
_logger.fine('Attempting access token refresh for 401 recovery');
await TokenRefreshMutex().executeRefresh(() async {
await refreshAccessToken();
return 'refreshed';
});
return;
}
// Fallback to rotation if refresh token expiring soon
_logger.fine('Attempting refresh token rotation for 401 recovery');
await TokenRefreshMutex().executeRotation(() async {
await _rotateRefreshToken();
});
} catch (e) {
_logger.severe('Token recovery failed: $e');
throw AuthenticationFailedException('Token recovery failed', toException(e));
}
},
maxRetries: 2,
shouldRetry: (error) {
// Only retry on network errors, not auth errors
return RetryHelper.isRetryableError(error) && !_isAuthError(error);
},
);
});
}
/// Enhanced getAccessToken with better error handling
static Future<String> getAccessTokenSafe() async {
return AuthCircuitBreaker.execute(() async {
return RetryHelper.withExponentialBackoff(
() async {
TokenData token = await AuthorizationStorage.getAccessToken();
if (_isTokenExpiringSoon(token, const Duration(hours: 4))) {
// Use mutex to prevent concurrent refresh operations
final refreshedToken = await TokenRefreshMutex().executeRefresh(() async {
return (await refreshAccessToken()).accessToken.token;
});
return refreshedToken;
}
return token.token;
},
maxRetries: 2,
shouldRetry: (error) => RetryHelper.isRetryableError(error),
);
});
}
/// Check if error is authentication-related (non-retryable)
static bool _isAuthError(Exception error) {
if (error is ErrorUnauthorized || error is AuthenticationFailedException) {
return true;
}
if (error is ErrorResponse && error.code == 401) {
return true;
}
final errorString = error.toString().toLowerCase();
return errorString.contains('unauthorized') ||
errorString.contains('401') ||
errorString.contains('authentication') ||
errorString.contains('token');
}
/// Get circuit breaker status for debugging
static Map<String, dynamic> getAuthStatus() {
return {
'circuitBreaker': AuthCircuitBreaker.getStatus(),
'tokenMutex': TokenRefreshMutex().getStatus(),
'timestamp': DateTime.now().toIso8601String(),
};
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:logging/logging.dart';
/// Mutex to prevent concurrent token refresh operations
/// This ensures only one refresh operation happens at a time,
/// preventing race conditions during app startup when multiple
/// providers try to refresh tokens simultaneously.
class TokenRefreshMutex {
static final _instance = TokenRefreshMutex._();
factory TokenRefreshMutex() => _instance;
TokenRefreshMutex._();
static final _logger = Logger('service.authorization.token_mutex');
Completer<String>? _currentRefresh;
Completer<void>? _currentRotation;
/// Execute a token refresh operation with mutex protection
/// If another refresh is in progress, wait for it to complete
Future<String> executeRefresh(Future<String> Function() refreshOperation) async {
if (_currentRefresh != null) {
_logger.fine('Token refresh already in progress, waiting for completion');
return await _currentRefresh!.future;
}
_logger.fine('Starting new token refresh operation');
_currentRefresh = Completer<String>();
try {
final result = await refreshOperation();
if (_currentRefresh != null) {
_currentRefresh!.complete(result);
_logger.fine('Token refresh completed successfully');
}
return result;
} catch (e, st) {
_logger.warning('Token refresh failed', e, st);
if (_currentRefresh != null) {
_currentRefresh!.completeError(e, st);
}
rethrow;
} finally {
_currentRefresh = null;
}
}
/// Execute a token rotation operation with mutex protection
/// If another rotation is in progress, wait for it to complete
Future<void> executeRotation(Future<void> Function() rotationOperation) async {
if (_currentRotation != null) {
_logger.fine('Token rotation already in progress, waiting for completion');
return await _currentRotation!.future;
}
_logger.fine('Starting new token rotation operation');
_currentRotation = Completer<void>();
try {
await rotationOperation();
if (_currentRotation != null) {
_currentRotation!.complete();
_logger.fine('Token rotation completed successfully');
}
} catch (e, st) {
_logger.warning('Token rotation failed', e, st);
if (_currentRotation != null) {
_currentRotation!.completeError(e, st);
}
rethrow;
} finally {
_currentRotation = null;
}
}
/// Check if a refresh operation is currently in progress
bool get isRefreshInProgress => _currentRefresh != null;
/// Check if a rotation operation is currently in progress
bool get isRotationInProgress => _currentRotation != null;
/// Get current status for debugging
Map<String, dynamic> getStatus() {
return {
'refreshInProgress': isRefreshInProgress,
'rotationInProgress': isRotationInProgress,
'timestamp': DateTime.now().toIso8601String(),
};
}
/// Force reset the mutex (for testing or emergency situations)
void forceReset() {
_logger.warning('Force resetting token refresh mutex');
if (_currentRefresh != null && !_currentRefresh!.isCompleted) {
_currentRefresh!.completeError(Exception('Mutex force reset'));
}
if (_currentRotation != null && !_currentRotation!.isCompleted) {
_currentRotation!.completeError(Exception('Mutex force reset'));
}
_currentRefresh = null;
_currentRotation = null;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:shared_preferences/shared_preferences.dart';
class SecureStorageService {
static Future<String?> get(String key) async {
final prefs = await SharedPreferences.getInstance();
@@ -18,6 +19,11 @@ class SecureStorageService {
return _setImp(prefs, key, value);
}
static Future<bool> containsKey(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.containsKey(key);
}
static Future<void> delete(String key) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);

View File

@@ -6,27 +6,13 @@ class Services {
static const String invitations = 'invitations';
static const String organization = 'organizations';
static const String permission = 'permissions';
static const String project = 'projects';
static const String pgroup = 'priority_groups';
static const String priorities = 'priorities';
static const String reactions = 'reactions';
static const String storage = 'storage';
static const String taskStatus = 'statuses';
static const String tasks = 'tasks';
static const String amplitude = 'amplitude';
static const String automations = 'automation';
static const String changes = 'changes';
static const String clients = 'clients';
static const String invoices = 'invoices';
static const String logo = 'logo';
static const String notifications = 'notifications';
static const String policies = 'policies';
static const String properties = 'properties';
static const String refreshTokens = 'refresh_tokens';
static const String roles = 'roles';
static const String steps = 'steps';
static const String teams = 'teams';
static const String workflows = 'workflows';
static const String workspaces = 'workspaces';
}