From 7b122d03daa5114c8d7184ac122cd3e1f4b6acd2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 10:15:34 +0200 Subject: [PATCH 1/2] feat(server): auth comptes utilisateurs et tokens API (ticket #48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la couche application (ports, use cases), les entités du domaine, l'endpoint api/auth_api.dart (register/login/logout) et son câblage dans le router, le hashing de mot de passe et le service de token (infrastructure/security), et l'adapter Postgres des repositories d'auth s'appuyant sur la table users du ticket #49. dart pub get OK, dart analyze clean, dart test 10/10 vert (test PostgreSQL réel skip faute de Docker disponible dans ce sandbox). Logique d'auth et middleware testés via repositories fake en mémoire ; les endpoints HTTP register/login/logout n'ont pas pu être exercés de bout en bout faute d'accès à un vrai PostgreSQL local. Co-Authored-By: Claude Opus 4.8 --- server/README.md | 20 +- server/bin/server.dart | 41 ++- server/lib/api/auth_api.dart | 161 +++++++++ server/lib/api/router.dart | 16 +- server/lib/application/README.md | 3 +- server/lib/application/application.dart | 2 + server/lib/application/ports.dart | 43 +++ server/lib/application/use_cases.dart | 190 ++++++++++ server/lib/domain/README.md | 2 + server/lib/domain/domain.dart | 1 + server/lib/domain/entities.dart | 97 +++++ .../postgres/auth_repositories.dart | 199 ++++++++++ .../lib/infrastructure/postgres/postgres.dart | 2 + server/lib/infrastructure/security/README.md | 6 +- .../security/password_hasher.dart | 88 +++++ .../security/runtime_services.dart | 32 ++ .../lib/infrastructure/security/security.dart | 3 + .../security/token_service.dart | 25 ++ server/pubspec.lock | 18 +- server/pubspec.yaml | 2 + server/test/auth_middleware_test.dart | 68 ++++ server/test/auth_use_cases_test.dart | 340 ++++++++++++++++++ 22 files changed, 1351 insertions(+), 8 deletions(-) create mode 100644 server/lib/api/auth_api.dart create mode 100644 server/lib/application/application.dart create mode 100644 server/lib/application/ports.dart create mode 100644 server/lib/application/use_cases.dart create mode 100644 server/lib/domain/domain.dart create mode 100644 server/lib/domain/entities.dart create mode 100644 server/lib/infrastructure/postgres/auth_repositories.dart create mode 100644 server/lib/infrastructure/postgres/postgres.dart create mode 100644 server/lib/infrastructure/security/password_hasher.dart create mode 100644 server/lib/infrastructure/security/runtime_services.dart create mode 100644 server/lib/infrastructure/security/security.dart create mode 100644 server/lib/infrastructure/security/token_service.dart create mode 100644 server/test/auth_middleware_test.dart create mode 100644 server/test/auth_use_cases_test.dart diff --git a/server/README.md b/server/README.md index 8b29a32..57cae72 100644 --- a/server/README.md +++ b/server/README.md @@ -69,11 +69,27 @@ Expected response: {"status":"ok"} ``` +## Authentication + +Ticket #48 adds account registration, login, logout and bearer-token request +authentication. + +Endpoints: + +- `POST /auth/register` with `{ "email": "...", "password": "...", "displayName": "..." }`. +- `POST /auth/login` with `{ "email": "...", "password": "...", "deviceLabel": "..." }`. +- `POST /auth/logout` with `Authorization: Bearer `. + +Passwords are stored with PBKDF2-HMAC-SHA256 via `package:cryptography`, using a +per-password random salt. API tokens are opaque random values; only a SHA-256 +hash of the token is stored in PostgreSQL. + ## Scope Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and -the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema only; auth, -sync endpoints and sharing behavior are still implemented in later tickets. +the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48 +adds authentication only; sync endpoints and sharing behavior are still +implemented in later tickets. Upcoming tickets will fill the empty adapters and use cases: diff --git a/server/bin/server.dart b/server/bin/server.dart index 9375207..e245c3e 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -1,12 +1,51 @@ import 'dart:io'; +import 'package:gametime_server/api/auth_api.dart'; import 'package:gametime_server/api/router.dart'; +import 'package:gametime_server/application/application.dart'; +import 'package:gametime_server/infrastructure/postgres/postgres.dart'; +import 'package:gametime_server/infrastructure/security/security.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; Future main(List arguments) async { final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080; + final connection = await PostgresConnectionFactory( + PostgresConnectionConfig.fromEnvironment(), + ).open(); + + final users = PostgresUserRepository(connection); + final sessions = PostgresAuthSessionRepository(connection); + final passwordHasher = Pbkdf2PasswordHasher(); + final tokens = SecureOpaqueTokenService(); + const clock = SystemClock(); + final ids = UuidV4Generator(); + final authenticateRequest = AuthenticateRequestUseCase( + users: users, + sessions: sessions, + tokens: tokens, + clock: clock, + ); + final authApi = AuthApi( + registerUser: RegisterUserUseCase( + users: users, + passwordHasher: passwordHasher, + clock: clock, + ids: ids, + ), + login: LoginUseCase( + users: users, + sessions: sessions, + passwordHasher: passwordHasher, + tokens: tokens, + clock: clock, + ids: ids, + ), + logout: LogoutUseCase(sessions: sessions, tokens: tokens, clock: clock), + authenticateRequest: authenticateRequest, + ); + final server = await shelf_io.serve( - buildApiHandler(), + buildApiHandler(authApi: authApi), InternetAddress.anyIPv4, port, ); diff --git a/server/lib/api/auth_api.dart b/server/lib/api/auth_api.dart new file mode 100644 index 0000000..dd222f6 --- /dev/null +++ b/server/lib/api/auth_api.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; + +import 'package:shelf/shelf.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; + +const authenticatedRequestContextKey = 'gametime.authenticatedRequest'; +const bearerTokenContextKey = 'gametime.bearerToken'; + +typedef AuthenticateToken = + Future Function({required String token}); + +final class AuthApi { + const AuthApi({ + required this.registerUser, + required this.login, + required this.logout, + required this.authenticateRequest, + }); + + final RegisterUserUseCase registerUser; + final LoginUseCase login; + final LogoutUseCase logout; + final AuthenticateRequestUseCase authenticateRequest; + + Future register(Request request) async { + try { + final body = await _readJsonObject(request); + final user = await registerUser.execute( + email: _requiredString(body, 'email'), + password: _requiredString(body, 'password'), + displayName: _optionalString(body, 'displayName'), + ); + return _jsonResponse(201, {'userId': user.id, 'email': user.email}); + } on EmailAlreadyTakenException catch (error) { + return _errorResponse(409, error.message); + } on ValidationException catch (error) { + return _errorResponse(400, error.message); + } on FormatException catch (error) { + return _errorResponse(400, error.message); + } + } + + Future loginUser(Request request) async { + try { + final body = await _readJsonObject(request); + final result = await login.execute( + email: _requiredString(body, 'email'), + password: _requiredString(body, 'password'), + userAgent: request.headers['user-agent'], + deviceLabel: _optionalString(body, 'deviceLabel'), + ); + return _jsonResponse(200, { + 'token': result.token, + 'expiresAt': result.expiresAt.toIso8601String(), + }); + } on InvalidCredentialsException catch (error) { + return _errorResponse(401, error.message); + } on ValidationException catch (error) { + return _errorResponse(400, error.message); + } on FormatException catch (error) { + return _errorResponse(400, error.message); + } + } + + Future logoutUser(Request request) async { + final token = request.context[bearerTokenContextKey] as String?; + if (token == null) { + return _errorResponse(401, 'Unauthorized.'); + } + try { + await logout.execute(token: token); + return Response(204); + } on UnauthorizedException catch (error) { + return _errorResponse(401, error.message); + } + } +} + +Middleware authenticationMiddleware(AuthenticateToken authenticate) { + return (innerHandler) { + return (request) async { + final token = _bearerToken(request.headers['authorization']); + if (token == null) { + return _errorResponse(401, 'Missing bearer token.'); + } + + try { + final authenticated = await authenticate(token: token); + return innerHandler( + request.change( + context: { + authenticatedRequestContextKey: authenticated, + bearerTokenContextKey: token, + }, + ), + ); + } on UnauthorizedException catch (error) { + return _errorResponse(401, error.message); + } + }; + }; +} + +AuthenticatedRequest? authenticatedRequestFrom(Request request) { + return request.context[authenticatedRequestContextKey] + as AuthenticatedRequest?; +} + +String? _bearerToken(String? authorization) { + if (authorization == null) { + return null; + } + final parts = authorization.trim().split(RegExp(r'\s+')); + if (parts.length != 2 || parts.first.toLowerCase() != 'bearer') { + return null; + } + return parts[1].isEmpty ? null : parts[1]; +} + +Future> _readJsonObject(Request request) async { + final raw = await request.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Request body must be a JSON object.'); + } + return decoded; +} + +String _requiredString(Map body, String key) { + final value = body[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException('$key must be a non-empty string.'); + } + return value; +} + +String? _optionalString(Map body, String key) { + final value = body[key]; + if (value == null) { + return null; + } + if (value is! String) { + throw FormatException('$key must be a string.'); + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +Response _jsonResponse(int statusCode, Map body) { + return Response( + statusCode, + body: jsonEncode(body), + headers: {'content-type': 'application/json'}, + ); +} + +Response _errorResponse(int statusCode, String message) { + return _jsonResponse(statusCode, {'error': message}); +} diff --git a/server/lib/api/router.dart b/server/lib/api/router.dart index dce7fcd..30fc846 100644 --- a/server/lib/api/router.dart +++ b/server/lib/api/router.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; -Handler buildApiHandler() { +import 'auth_api.dart'; + +Handler buildApiHandler({AuthApi? authApi}) { final router = Router() ..get('/health', (Request request) { return Response.ok( @@ -12,5 +14,17 @@ Handler buildApiHandler() { ); }); + if (authApi != null) { + router + ..post('/auth/register', authApi.register) + ..post('/auth/login', authApi.loginUser) + ..post( + '/auth/logout', + authenticationMiddleware(authApi.authenticateRequest.execute)( + authApi.logoutUser, + ), + ); + } + return const Pipeline().addMiddleware(logRequests()).addHandler(router.call); } diff --git a/server/lib/application/README.md b/server/lib/application/README.md index d26d3be..d32ca65 100644 --- a/server/lib/application/README.md +++ b/server/lib/application/README.md @@ -2,4 +2,5 @@ Server use cases, ports and API DTOs independent from Shelf and PostgreSQL. -Concrete adapters are wired from `bin/server.dart`. +Authentication use cases live here and depend only on repository/security +ports. Concrete adapters are wired from `bin/server.dart`. diff --git a/server/lib/application/application.dart b/server/lib/application/application.dart new file mode 100644 index 0000000..6f17d49 --- /dev/null +++ b/server/lib/application/application.dart @@ -0,0 +1,2 @@ +export 'ports.dart'; +export 'use_cases.dart'; diff --git a/server/lib/application/ports.dart b/server/lib/application/ports.dart new file mode 100644 index 0000000..4940846 --- /dev/null +++ b/server/lib/application/ports.dart @@ -0,0 +1,43 @@ +import '../domain/domain.dart'; + +abstract interface class UserRepository { + Future findByEmail(String email); + + Future findById(String id); + + Future insert(UserAccount user); + + Future updatePasswordHash({ + required String userId, + required String passwordHash, + required DateTime updatedAt, + }); +} + +abstract interface class AuthSessionRepository { + Future insert(AuthSession session); + + Future findByTokenHash(String tokenHash); + + Future revoke({required String sessionId, required DateTime revokedAt}); +} + +abstract interface class PasswordHasher { + Future hash(String password); + + Future verify({required String password, required String passwordHash}); +} + +abstract interface class OpaqueTokenService { + String generateToken(); + + String hashToken(String token); +} + +abstract interface class Clock { + DateTime now(); +} + +abstract interface class IdGenerator { + String newId(); +} diff --git a/server/lib/application/use_cases.dart b/server/lib/application/use_cases.dart new file mode 100644 index 0000000..9c1167c --- /dev/null +++ b/server/lib/application/use_cases.dart @@ -0,0 +1,190 @@ +import '../domain/domain.dart'; +import 'ports.dart'; + +const _minimumPasswordLength = 8; +final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + +final class RegisterUserUseCase { + const RegisterUserUseCase({ + required this.users, + required this.passwordHasher, + required this.clock, + required this.ids, + }); + + final UserRepository users; + final PasswordHasher passwordHasher; + final Clock clock; + final IdGenerator ids; + + Future execute({ + required String email, + required String password, + String? displayName, + }) async { + final normalizedEmail = _validateEmail(email); + _validatePassword(password); + + final existing = await users.findByEmail(normalizedEmail); + if (existing != null) { + throw const EmailAlreadyTakenException(); + } + + final now = clock.now().toUtc(); + final user = UserAccount( + id: ids.newId(), + email: normalizedEmail, + passwordHash: await passwordHasher.hash(password), + displayName: _blankToNull(displayName), + createdAt: now, + updatedAt: now, + ); + await users.insert(user); + return user; + } +} + +final class LoginResult { + const LoginResult({ + required this.user, + required this.session, + required this.token, + }); + + final UserAccount user; + final AuthSession session; + final String token; + + DateTime get expiresAt => session.expiresAt; +} + +final class LoginUseCase { + const LoginUseCase({ + required this.users, + required this.sessions, + required this.passwordHasher, + required this.tokens, + required this.clock, + required this.ids, + this.sessionDuration = const Duration(days: 30), + }); + + final UserRepository users; + final AuthSessionRepository sessions; + final PasswordHasher passwordHasher; + final OpaqueTokenService tokens; + final Clock clock; + final IdGenerator ids; + final Duration sessionDuration; + + Future execute({ + required String email, + required String password, + String? userAgent, + String? deviceLabel, + }) async { + final normalizedEmail = _validateEmail(email); + final user = await users.findByEmail(normalizedEmail); + if (user == null || user.isDisabled) { + throw const InvalidCredentialsException(); + } + final verified = await passwordHasher.verify( + password: password, + passwordHash: user.passwordHash, + ); + if (!verified) { + throw const InvalidCredentialsException(); + } + + final now = clock.now().toUtc(); + final token = tokens.generateToken(); + final session = AuthSession( + id: ids.newId(), + userId: user.id, + tokenHash: tokens.hashToken(token), + issuedAt: now, + expiresAt: now.add(sessionDuration), + userAgent: _blankToNull(userAgent), + deviceLabel: _blankToNull(deviceLabel), + ); + await sessions.insert(session); + return LoginResult(user: user, session: session, token: token); + } +} + +final class LogoutUseCase { + const LogoutUseCase({ + required this.sessions, + required this.tokens, + required this.clock, + }); + + final AuthSessionRepository sessions; + final OpaqueTokenService tokens; + final Clock clock; + + Future execute({required String token}) async { + final session = await sessions.findByTokenHash(tokens.hashToken(token)); + if (session == null || !session.isActiveAt(clock.now())) { + throw const UnauthorizedException(); + } + await sessions.revoke( + sessionId: session.id, + revokedAt: clock.now().toUtc(), + ); + } +} + +final class AuthenticatedRequest { + const AuthenticatedRequest({required this.user, required this.session}); + + final UserAccount user; + final AuthSession session; +} + +final class AuthenticateRequestUseCase { + const AuthenticateRequestUseCase({ + required this.users, + required this.sessions, + required this.tokens, + required this.clock, + }); + + final UserRepository users; + final AuthSessionRepository sessions; + final OpaqueTokenService tokens; + final Clock clock; + + Future execute({required String token}) async { + final session = await sessions.findByTokenHash(tokens.hashToken(token)); + if (session == null || !session.isActiveAt(clock.now())) { + throw const UnauthorizedException(); + } + final user = await users.findById(session.userId); + if (user == null || user.isDisabled) { + throw const UnauthorizedException(); + } + return AuthenticatedRequest(user: user, session: session); + } +} + +String _validateEmail(String email) { + final normalized = email.trim().toLowerCase(); + if (!_emailPattern.hasMatch(normalized)) { + throw const ValidationException('Email format is invalid.'); + } + return normalized; +} + +void _validatePassword(String password) { + if (password.length < _minimumPasswordLength) { + throw const ValidationException( + 'Password must contain at least 8 characters.', + ); + } +} + +String? _blankToNull(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} diff --git a/server/lib/domain/README.md b/server/lib/domain/README.md index 7260459..13de062 100644 --- a/server/lib/domain/README.md +++ b/server/lib/domain/README.md @@ -4,3 +4,5 @@ Pure server domain entities and invariants. This layer must not import Shelf, PostgreSQL adapters, Docker configuration or other infrastructure concerns. + +Current entities: `UserAccount` and `AuthSession`. diff --git a/server/lib/domain/domain.dart b/server/lib/domain/domain.dart new file mode 100644 index 0000000..8855461 --- /dev/null +++ b/server/lib/domain/domain.dart @@ -0,0 +1 @@ +export 'entities.dart'; diff --git a/server/lib/domain/entities.dart b/server/lib/domain/entities.dart new file mode 100644 index 0000000..e015448 --- /dev/null +++ b/server/lib/domain/entities.dart @@ -0,0 +1,97 @@ +final class DomainException implements Exception { + const DomainException(this.message); + + final String message; + + @override + String toString() => message; +} + +final class ValidationException extends DomainException { + const ValidationException(super.message); +} + +final class EmailAlreadyTakenException extends DomainException { + const EmailAlreadyTakenException() : super('Email already taken.'); +} + +final class InvalidCredentialsException extends DomainException { + const InvalidCredentialsException() : super('Invalid credentials.'); +} + +final class UnauthorizedException extends DomainException { + const UnauthorizedException() : super('Unauthorized.'); +} + +final class UserAccount { + UserAccount({ + required String id, + required String email, + required String passwordHash, + this.displayName, + required DateTime createdAt, + required DateTime updatedAt, + DateTime? disabledAt, + }) : id = _nonBlank(id, 'User id'), + email = _nonBlank(email, 'Email').toLowerCase(), + passwordHash = _nonBlank(passwordHash, 'Password hash'), + createdAt = createdAt.toUtc(), + updatedAt = updatedAt.toUtc(), + disabledAt = disabledAt?.toUtc(); + + final String id; + final String email; + final String passwordHash; + final String? displayName; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? disabledAt; + + bool get isDisabled => disabledAt != null; +} + +final class AuthSession { + AuthSession({ + required String id, + required String userId, + required String tokenHash, + required DateTime issuedAt, + required DateTime expiresAt, + DateTime? revokedAt, + this.userAgent, + this.deviceLabel, + }) : id = _nonBlank(id, 'Auth session id'), + userId = _nonBlank(userId, 'User id'), + tokenHash = _nonBlank(tokenHash, 'Token hash'), + issuedAt = issuedAt.toUtc(), + expiresAt = expiresAt.toUtc(), + revokedAt = revokedAt?.toUtc() { + if (!this.expiresAt.isAfter(this.issuedAt)) { + throw const ValidationException( + 'Auth session expiration must be after issue time.', + ); + } + } + + final String id; + final String userId; + final String tokenHash; + final DateTime issuedAt; + final DateTime expiresAt; + final DateTime? revokedAt; + final String? userAgent; + final String? deviceLabel; + + bool isActiveAt(DateTime now) { + final instant = now.toUtc(); + return revokedAt == null && expiresAt.isAfter(instant); + } +} + +String _nonBlank(String value, String label) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ValidationException('$label must not be blank.'); + } + return trimmed; +} diff --git a/server/lib/infrastructure/postgres/auth_repositories.dart b/server/lib/infrastructure/postgres/auth_repositories.dart new file mode 100644 index 0000000..cb4db95 --- /dev/null +++ b/server/lib/infrastructure/postgres/auth_repositories.dart @@ -0,0 +1,199 @@ +import 'package:postgres/postgres.dart'; + +import '../../application/application.dart'; +import '../../domain/domain.dart'; + +final class PostgresUserRepository implements UserRepository { + const PostgresUserRepository(this.connection); + + final Connection connection; + + @override + Future findByEmail(String email) async { + final result = await connection.execute( + Sql.named(''' + SELECT id, email, password_hash, display_name, created_at, updated_at, + disabled_at + FROM users + WHERE email = @email + LIMIT 1 + '''), + parameters: {'email': email.trim().toLowerCase()}, + ); + return result.isEmpty ? null : _userFromRow(result.single); + } + + @override + Future findById(String id) async { + final result = await connection.execute( + Sql.named(''' + SELECT id, email, password_hash, display_name, created_at, updated_at, + disabled_at + FROM users + WHERE id = @id + LIMIT 1 + '''), + parameters: {'id': id}, + ); + return result.isEmpty ? null : _userFromRow(result.single); + } + + @override + Future insert(UserAccount user) async { + await connection.execute( + Sql.named(''' + INSERT INTO users ( + id, email, password_hash, display_name, created_at, updated_at, + disabled_at + ) + VALUES ( + @id, @email, @password_hash, @display_name, @created_at, + @updated_at, @disabled_at + ) + '''), + parameters: { + 'id': user.id, + 'email': user.email, + 'password_hash': user.passwordHash, + 'display_name': user.displayName, + 'created_at': user.createdAt, + 'updated_at': user.updatedAt, + 'disabled_at': user.disabledAt, + }, + ); + } + + @override + Future updatePasswordHash({ + required String userId, + required String passwordHash, + required DateTime updatedAt, + }) async { + await connection.execute( + Sql.named(''' + UPDATE users + SET password_hash = @password_hash, updated_at = @updated_at + WHERE id = @user_id + '''), + parameters: { + 'user_id': userId, + 'password_hash': passwordHash, + 'updated_at': updatedAt.toUtc(), + }, + ); + } +} + +final class PostgresAuthSessionRepository implements AuthSessionRepository { + const PostgresAuthSessionRepository(this.connection); + + final Connection connection; + + @override + Future insert(AuthSession session) async { + await connection.execute( + Sql.named(''' + INSERT INTO auth_sessions ( + id, user_id, token_hash, issued_at, expires_at, revoked_at, + user_agent, device_label + ) + VALUES ( + @id, @user_id, @token_hash, @issued_at, @expires_at, @revoked_at, + @user_agent, @device_label + ) + '''), + parameters: { + 'id': session.id, + 'user_id': session.userId, + 'token_hash': session.tokenHash, + 'issued_at': session.issuedAt, + 'expires_at': session.expiresAt, + 'revoked_at': session.revokedAt, + 'user_agent': session.userAgent, + 'device_label': session.deviceLabel, + }, + ); + } + + @override + Future findByTokenHash(String tokenHash) async { + final result = await connection.execute( + Sql.named(''' + SELECT id, user_id, token_hash, issued_at, expires_at, revoked_at, + user_agent, device_label + FROM auth_sessions + WHERE token_hash = @token_hash + LIMIT 1 + '''), + parameters: {'token_hash': tokenHash}, + ); + return result.isEmpty ? null : _authSessionFromRow(result.single); + } + + @override + Future revoke({ + required String sessionId, + required DateTime revokedAt, + }) async { + await connection.execute( + Sql.named(''' + UPDATE auth_sessions + SET revoked_at = @revoked_at + WHERE id = @id AND revoked_at IS NULL + '''), + parameters: {'id': sessionId, 'revoked_at': revokedAt.toUtc()}, + ); + } +} + +UserAccount _userFromRow(dynamic row) { + final values = row.toColumnMap() as Map; + return UserAccount( + id: _stringValue(values['id']), + email: _stringValue(values['email']), + passwordHash: _stringValue(values['password_hash']), + displayName: values['display_name'] as String?, + createdAt: _dateTimeValue(values['created_at']), + updatedAt: _dateTimeValue(values['updated_at']), + disabledAt: _nullableDateTimeValue(values['disabled_at']), + ); +} + +AuthSession _authSessionFromRow(dynamic row) { + final values = row.toColumnMap() as Map; + return AuthSession( + id: _stringValue(values['id']), + userId: _stringValue(values['user_id']), + tokenHash: _stringValue(values['token_hash']), + issuedAt: _dateTimeValue(values['issued_at']), + expiresAt: _dateTimeValue(values['expires_at']), + revokedAt: _nullableDateTimeValue(values['revoked_at']), + userAgent: values['user_agent'] as String?, + deviceLabel: values['device_label'] as String?, + ); +} + +String _stringValue(Object? value) { + if (value == null) { + throw const FormatException('Expected non-null string value.'); + } + return value.toString(); +} + +DateTime _dateTimeValue(Object? value) { + final result = _nullableDateTimeValue(value); + if (result == null) { + throw const FormatException('Expected non-null DateTime value.'); + } + return result; +} + +DateTime? _nullableDateTimeValue(Object? value) { + if (value == null) { + return null; + } + if (value is DateTime) { + return value.toUtc(); + } + return DateTime.parse(value.toString()).toUtc(); +} diff --git a/server/lib/infrastructure/postgres/postgres.dart b/server/lib/infrastructure/postgres/postgres.dart new file mode 100644 index 0000000..968784d --- /dev/null +++ b/server/lib/infrastructure/postgres/postgres.dart @@ -0,0 +1,2 @@ +export 'auth_repositories.dart'; +export 'postgres_database.dart'; diff --git a/server/lib/infrastructure/security/README.md b/server/lib/infrastructure/security/README.md index 8297849..2dd2b8d 100644 --- a/server/lib/infrastructure/security/README.md +++ b/server/lib/infrastructure/security/README.md @@ -1,5 +1,7 @@ # Security infrastructure -Placeholder for password hashing, token signing and token verification. +Password hashing uses PBKDF2-HMAC-SHA256 via `package:cryptography`, with a +random per-password salt and encoded parameters in the stored hash. -Authentication is planned for ticket #48. +API tokens are opaque random base64url strings generated with `Random.secure()`. +Only the SHA-256 hash of a token is persisted. diff --git a/server/lib/infrastructure/security/password_hasher.dart b/server/lib/infrastructure/security/password_hasher.dart new file mode 100644 index 0000000..b730100 --- /dev/null +++ b/server/lib/infrastructure/security/password_hasher.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; + +import '../../application/application.dart'; +import '../../domain/domain.dart'; + +final class Pbkdf2PasswordHasher implements PasswordHasher { + Pbkdf2PasswordHasher({ + this.iterations = 210000, + this.saltLength = 16, + this.bits = 256, + Random? random, + }) : _random = random ?? Random.secure(); + + final int iterations; + final int saltLength; + final int bits; + final Random _random; + + @override + Future hash(String password) async { + final salt = List.generate(saltLength, (_) => _random.nextInt(256)); + final hash = await _derive(password, salt, iterations, bits); + return [ + 'pbkdf2_sha256', + iterations.toString(), + base64UrlEncode(salt), + base64UrlEncode(hash), + ].join(r'$'); + } + + @override + Future verify({ + required String password, + required String passwordHash, + }) async { + final parts = passwordHash.split(r'$'); + if (parts.length != 4 || parts[0] != 'pbkdf2_sha256') { + throw const ValidationException('Unsupported password hash format.'); + } + + final parsedIterations = int.tryParse(parts[1]); + if (parsedIterations == null || parsedIterations <= 0) { + throw const ValidationException('Invalid password hash iterations.'); + } + + final salt = base64Url.decode(parts[2]); + final expectedHash = base64Url.decode(parts[3]); + final actualHash = await _derive( + password, + salt, + parsedIterations, + expectedHash.length * 8, + ); + return _constantTimeEquals(actualHash, expectedHash); + } + + Future> _derive( + String password, + List salt, + int iterations, + int bits, + ) async { + final algorithm = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: iterations, + bits: bits, + ); + final key = await algorithm.deriveKey( + secretKey: SecretKey(utf8.encode(password)), + nonce: salt, + ); + return key.extractBytes(); + } +} + +bool _constantTimeEquals(List left, List right) { + if (left.length != right.length) { + return false; + } + var diff = 0; + for (var index = 0; index < left.length; index++) { + diff |= left[index] ^ right[index]; + } + return diff == 0; +} diff --git a/server/lib/infrastructure/security/runtime_services.dart b/server/lib/infrastructure/security/runtime_services.dart new file mode 100644 index 0000000..cb2dac1 --- /dev/null +++ b/server/lib/infrastructure/security/runtime_services.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import '../../application/application.dart'; + +final class SystemClock implements Clock { + const SystemClock(); + + @override + DateTime now() => DateTime.now().toUtc(); +} + +final class UuidV4Generator implements IdGenerator { + UuidV4Generator({Random? random}) : _random = random ?? Random.secure(); + + final Random _random; + + @override + String newId() { + final bytes = List.generate(16, (_) => _random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')); + final value = hex.join(); + return [ + value.substring(0, 8), + value.substring(8, 12), + value.substring(12, 16), + value.substring(16, 20), + value.substring(20), + ].join('-'); + } +} diff --git a/server/lib/infrastructure/security/security.dart b/server/lib/infrastructure/security/security.dart new file mode 100644 index 0000000..fd57a26 --- /dev/null +++ b/server/lib/infrastructure/security/security.dart @@ -0,0 +1,3 @@ +export 'password_hasher.dart'; +export 'runtime_services.dart'; +export 'token_service.dart'; diff --git a/server/lib/infrastructure/security/token_service.dart b/server/lib/infrastructure/security/token_service.dart new file mode 100644 index 0000000..bdeb29b --- /dev/null +++ b/server/lib/infrastructure/security/token_service.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +import '../../application/application.dart'; + +final class SecureOpaqueTokenService implements OpaqueTokenService { + SecureOpaqueTokenService({this.byteLength = 32, Random? random}) + : _random = random ?? Random.secure(); + + final int byteLength; + final Random _random; + + @override + String generateToken() { + final bytes = List.generate(byteLength, (_) => _random.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); + } + + @override + String hashToken(String token) { + return sha256.convert(utf8.encode(token)).toString(); + } +} diff --git a/server/pubspec.lock b/server/pubspec.lock index 0c3dbb2..3c163d4 100644 --- a/server/pubspec.lock +++ b/server/pubspec.lock @@ -90,13 +90,29 @@ packages: source: hosted version: "1.15.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: diff --git a/server/pubspec.yaml b/server/pubspec.yaml index ddf0ff2..40deb83 100644 --- a/server/pubspec.yaml +++ b/server/pubspec.yaml @@ -6,6 +6,8 @@ environment: sdk: ^3.10.0 dependencies: + crypto: ^3.0.6 + cryptography: ^2.7.0 postgres: ^3.5.12 shelf: ^1.4.2 shelf_router: ^1.1.4 diff --git a/server/test/auth_middleware_test.dart b/server/test/auth_middleware_test.dart new file mode 100644 index 0000000..2a68580 --- /dev/null +++ b/server/test/auth_middleware_test.dart @@ -0,0 +1,68 @@ +import 'package:gametime_server/api/auth_api.dart'; +import 'package:gametime_server/application/application.dart'; +import 'package:gametime_server/domain/domain.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +void main() { + test('authentication middleware injects the authenticated request', () async { + final user = UserAccount( + id: 'user-1', + email: 'user@example.com', + passwordHash: 'hash', + createdAt: DateTime.utc(2026, 7, 19, 12), + updatedAt: DateTime.utc(2026, 7, 19, 12), + ); + final session = AuthSession( + id: 'session-1', + userId: user.id, + tokenHash: 'token-hash', + issuedAt: DateTime.utc(2026, 7, 19, 12), + expiresAt: DateTime.utc(2026, 7, 20, 12), + ); + final tokens = []; + final handler = + authenticationMiddleware(({required token}) async { + tokens.add(token); + return AuthenticatedRequest(user: user, session: session); + })((request) { + final authenticated = authenticatedRequestFrom(request); + return Response.ok(authenticated?.user.id ?? 'missing'); + }); + + final response = await handler( + Request( + 'GET', + Uri.parse('http://localhost/protected'), + headers: {'authorization': 'Bearer clear-token'}, + ), + ); + + expect(response.statusCode, 200); + expect(await response.readAsString(), 'user-1'); + expect(tokens, ['clear-token']); + }); + + test( + 'authentication middleware rejects missing or invalid bearer tokens', + () async { + final handler = authenticationMiddleware(({required token}) async { + throw const UnauthorizedException(); + })((request) => Response.ok('secret')); + + final missing = await handler( + Request('GET', Uri.parse('http://localhost/protected')), + ); + final invalid = await handler( + Request( + 'GET', + Uri.parse('http://localhost/protected'), + headers: {'authorization': 'Bearer invalid-token'}, + ), + ); + + expect(missing.statusCode, 401); + expect(invalid.statusCode, 401); + }, + ); +} diff --git a/server/test/auth_use_cases_test.dart b/server/test/auth_use_cases_test.dart new file mode 100644 index 0000000..e92ba06 --- /dev/null +++ b/server/test/auth_use_cases_test.dart @@ -0,0 +1,340 @@ +import 'package:gametime_server/application/application.dart'; +import 'package:gametime_server/domain/domain.dart'; +import 'package:test/test.dart'; + +void main() { + late _FakeUserRepository users; + late _FakeAuthSessionRepository sessions; + late _FakePasswordHasher passwordHasher; + late _FakeTokenService tokens; + late _FakeClock clock; + late _FakeIds ids; + + setUp(() { + users = _FakeUserRepository(); + sessions = _FakeAuthSessionRepository(); + passwordHasher = _FakePasswordHasher(); + tokens = _FakeTokenService(); + clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); + ids = _FakeIds(); + }); + + test('register creates a user with a hashed password', () async { + final useCase = RegisterUserUseCase( + users: users, + passwordHasher: passwordHasher, + clock: clock, + ids: ids, + ); + + final user = await useCase.execute( + email: ' USER@Example.COM ', + password: 'password123', + displayName: ' Anthony ', + ); + + expect(user.id, 'id-1'); + expect(user.email, 'user@example.com'); + expect(user.passwordHash, 'hashed:password123'); + expect(user.displayName, 'Anthony'); + expect(users.byId[user.id], user); + }); + + test('register rejects an already used email', () async { + users.add( + UserAccount( + id: 'existing-user', + email: 'user@example.com', + passwordHash: 'hash', + createdAt: clock.now(), + updatedAt: clock.now(), + ), + ); + final useCase = RegisterUserUseCase( + users: users, + passwordHasher: passwordHasher, + clock: clock, + ids: ids, + ); + + await expectLater( + useCase.execute(email: 'USER@example.com', password: 'password123'), + throwsA(isA()), + ); + }); + + test('login returns a clear token once and stores only its hash', () async { + users.add( + UserAccount( + id: 'user-1', + email: 'user@example.com', + passwordHash: 'hashed:password123', + createdAt: clock.now(), + updatedAt: clock.now(), + ), + ); + tokens.nextToken = 'clear-token'; + final useCase = LoginUseCase( + users: users, + sessions: sessions, + passwordHasher: passwordHasher, + tokens: tokens, + clock: clock, + ids: ids, + ); + + final result = await useCase.execute( + email: 'user@example.com', + password: 'password123', + userAgent: 'test-agent', + deviceLabel: 'phone', + ); + + expect(result.token, 'clear-token'); + expect(result.expiresAt, DateTime.utc(2026, 8, 18, 12)); + expect(sessions.byTokenHash.keys.single, 'token-hash:clear-token'); + expect(sessions.byTokenHash.values.single.tokenHash, isNot('clear-token')); + }); + + test('login rejects a bad password', () async { + users.add( + UserAccount( + id: 'user-1', + email: 'user@example.com', + passwordHash: 'hashed:password123', + createdAt: clock.now(), + updatedAt: clock.now(), + ), + ); + final useCase = LoginUseCase( + users: users, + sessions: sessions, + passwordHasher: passwordHasher, + tokens: tokens, + clock: clock, + ids: ids, + ); + + await expectLater( + useCase.execute(email: 'user@example.com', password: 'wrong-password'), + throwsA(isA()), + ); + }); + + test('authenticate returns the user for a valid token', () async { + final user = _user(clock); + users.add(user); + sessions.add( + AuthSession( + id: 'session-1', + userId: user.id, + tokenHash: 'token-hash:valid-token', + issuedAt: clock.now(), + expiresAt: clock.now().add(const Duration(days: 1)), + ), + ); + final useCase = _authenticateUseCase(users, sessions, tokens, clock); + + final authenticated = await useCase.execute(token: 'valid-token'); + + expect(authenticated.user.id, user.id); + expect(authenticated.session.id, 'session-1'); + }); + + test('authenticate rejects expired, revoked and unknown tokens', () async { + final user = _user(clock); + users.add(user); + sessions + ..add( + AuthSession( + id: 'expired', + userId: user.id, + tokenHash: 'token-hash:expired-token', + issuedAt: clock.now().subtract(const Duration(days: 2)), + expiresAt: clock.now().subtract(const Duration(days: 1)), + ), + ) + ..add( + AuthSession( + id: 'revoked', + userId: user.id, + tokenHash: 'token-hash:revoked-token', + issuedAt: clock.now(), + expiresAt: clock.now().add(const Duration(days: 1)), + revokedAt: clock.now(), + ), + ); + final useCase = _authenticateUseCase(users, sessions, tokens, clock); + + await expectLater( + useCase.execute(token: 'expired-token'), + throwsA(isA()), + ); + await expectLater( + useCase.execute(token: 'revoked-token'), + throwsA(isA()), + ); + await expectLater( + useCase.execute(token: 'unknown-token'), + throwsA(isA()), + ); + }); + + test('logout revokes an active token', () async { + final user = _user(clock); + users.add(user); + sessions.add( + AuthSession( + id: 'session-1', + userId: user.id, + tokenHash: 'token-hash:valid-token', + issuedAt: clock.now(), + expiresAt: clock.now().add(const Duration(days: 1)), + ), + ); + final useCase = LogoutUseCase( + sessions: sessions, + tokens: tokens, + clock: clock, + ); + + await useCase.execute(token: 'valid-token'); + + expect(sessions.revokedSessionIds, ['session-1']); + }); +} + +AuthenticateRequestUseCase _authenticateUseCase( + _FakeUserRepository users, + _FakeAuthSessionRepository sessions, + _FakeTokenService tokens, + _FakeClock clock, +) { + return AuthenticateRequestUseCase( + users: users, + sessions: sessions, + tokens: tokens, + clock: clock, + ); +} + +UserAccount _user(_FakeClock clock) { + return UserAccount( + id: 'user-1', + email: 'user@example.com', + passwordHash: 'hashed:password123', + createdAt: clock.now(), + updatedAt: clock.now(), + ); +} + +final class _FakeUserRepository implements UserRepository { + final byId = {}; + final byEmail = {}; + + void add(UserAccount user) { + byId[user.id] = user; + byEmail[user.email] = user; + } + + @override + Future findByEmail(String email) async { + return byEmail[email.trim().toLowerCase()]; + } + + @override + Future findById(String id) async => byId[id]; + + @override + Future insert(UserAccount user) async => add(user); + + @override + Future updatePasswordHash({ + required String userId, + required String passwordHash, + required DateTime updatedAt, + }) async { + final user = byId[userId]; + if (user == null) { + return; + } + add( + UserAccount( + id: user.id, + email: user.email, + passwordHash: passwordHash, + displayName: user.displayName, + createdAt: user.createdAt, + updatedAt: updatedAt, + disabledAt: user.disabledAt, + ), + ); + } +} + +final class _FakeAuthSessionRepository implements AuthSessionRepository { + final byTokenHash = {}; + final revokedSessionIds = []; + + void add(AuthSession session) { + byTokenHash[session.tokenHash] = session; + } + + @override + Future findByTokenHash(String tokenHash) async { + return byTokenHash[tokenHash]; + } + + @override + Future insert(AuthSession session) async => add(session); + + @override + Future revoke({ + required String sessionId, + required DateTime revokedAt, + }) async { + revokedSessionIds.add(sessionId); + } +} + +final class _FakePasswordHasher implements PasswordHasher { + @override + Future hash(String password) async => 'hashed:$password'; + + @override + Future verify({ + required String password, + required String passwordHash, + }) async { + return passwordHash == 'hashed:$password'; + } +} + +final class _FakeTokenService implements OpaqueTokenService { + String nextToken = 'token'; + + @override + String generateToken() => nextToken; + + @override + String hashToken(String token) => 'token-hash:$token'; +} + +final class _FakeClock implements Clock { + _FakeClock(this.value); + + DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} From d6ca54174f426d901c783dabf3a575330a8c3e8c Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 10:15:40 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20les=20?= =?UTF-8?q?tickets=20#47/#49=20apr=C3=A8s=20l'auth=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/47/carnet.md | 6 +++--- .ideai/tickets/47/issue.md | 8 ++++---- .ideai/tickets/49/carnet.md | 7 ++++--- .ideai/tickets/49/issue.md | 8 ++++---- .ideai/tickets/index.json | 8 ++++---- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/.ideai/tickets/47/carnet.md b/.ideai/tickets/47/carnet.md index be7ee11..35263b5 100644 --- a/.ideai/tickets/47/carnet.md +++ b/.ideai/tickets/47/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#47" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784411985578 +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784448362531 --- diff --git a/.ideai/tickets/47/issue.md b/.ideai/tickets/47/issue.md index 03cb49c..a39bda1 100644 --- a/.ideai/tickets/47/issue.md +++ b/.ideai/tickets/47/issue.md @@ -2,15 +2,15 @@ id: "f3283335-8f96-40aa-88a4-bcc58a530ad2" number: 47 title: "[Server] Scaffolding serveur Dart headless hexagonal" -status: "open" +status: "closed" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784411985578 -updatedAt: 1784411985578 -version: 1 +updatedAt: 1784448362531 +version: 2 --- Créer le sous-répertoire `server/` à la racine avec une application Dart headless basée sur `shelf`/`shelf_router`, structure hexagonale (`domain/`, `application/`, `infrastructure/`, `api/`), configuration env, healthcheck et tests de base. Inclure `Dockerfile`, `.env.example`, `docker-compose.yaml` paramétrable et un README d'exploitation minimal. Le serveur doit écouter derrière reverse proxy sans HTTPS interne obligatoire. \ No newline at end of file diff --git a/.ideai/tickets/49/carnet.md b/.ideai/tickets/49/carnet.md index b251bcc..a3990ef 100644 --- a/.ideai/tickets/49/carnet.md +++ b/.ideai/tickets/49/carnet.md @@ -1,6 +1,7 @@ --- issueRef: "#49" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784411997184 +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784448367646 --- +Migrations SQL implémentées et revues manuellement (5 tables, contraintes, index, trigger `server_updated_at`), `dart analyze`/`dart test` verts. Non vérifié en conditions réelles : sandbox Main sans accès au daemon Docker → impossible de lancer un vrai PostgreSQL et d'exécuter `TEST_DATABASE_URL=... dart test` ou `dart run bin/migrate.dart` contre une vraie base. À faire avant mise en prod : lancer `docker compose up -d postgres` (une fois #52 livré) et rejouer `bin/migrate.dart` + le test d'intégration pour confirmer que les migrations s'appliquent sans erreur sur un vrai PostgreSQL 16. \ No newline at end of file diff --git a/.ideai/tickets/49/issue.md b/.ideai/tickets/49/issue.md index 004d7c5..fd74406 100644 --- a/.ideai/tickets/49/issue.md +++ b/.ideai/tickets/49/issue.md @@ -2,15 +2,15 @@ id: "c73b39be-061c-44b4-b5c3-f482878fe58d" number: 49 title: "[Server] Schéma PostgreSQL sync-ready GameTime" -status: "open" +status: "qa" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#47","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784411997184 -updatedAt: 1784411997184 -version: 1 +updatedAt: 1784448367646 +version: 3 --- Créer le modèle serveur PostgreSQL pour les ressources synchronisables : Exercise, Program, WorkoutTemplate, WorkoutHistory, MediaAsset metadata, plus tables d'ownership par utilisateur. Chaque ressource doit stocker `ownerUserId`, `clientId`, `serverId`, `payloadJson`, `clientUpdatedAt`, `serverUpdatedAt`, `deletedAt`, `schemaVersion`, `originDeviceId`. Prévoir contraintes d'unicité `(owner_user_id, resource_type, client_id)`, index pull incrémental `(owner_user_id, resource_type, server_updated_at)`, migrations et tests repository. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 09483fb..f778346 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -537,11 +537,11 @@ "issueRef": "#47", "path": "47", "title": "[Server] Scaffolding serveur Dart headless hexagonal", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784411985578 + "updatedAt": 1784448362531 }, { "issueRef": "#48", @@ -557,11 +557,11 @@ "issueRef": "#49", "path": "49", "title": "[Server] Schéma PostgreSQL sync-ready GameTime", - "status": "open", + "status": "qa", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784411997184 + "updatedAt": 1784448367646 }, { "issueRef": "#50",