// Enrollment storage — mirrors k_proxy_app.py ProxyState enrollment logic. // Persists to a JSON file with the same schema so snapshots are portable. import 'dart:convert'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; // --------------------------------------------------------------------------- // Username validation // --------------------------------------------------------------------------- final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$'); String normalizeUsername(String raw) { final s = raw.trim().toLowerCase(); if (!_usernamePattern.hasMatch(s)) { throw ArgumentError( 'username must be 3–32 chars of lowercase letters, digits, dot, underscore, or dash'); } return s; } String? normalizeDisplayName(String? raw) { final s = (raw ?? '').trim(); if (s.isEmpty) return null; if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer'); return s; } // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- class Enrollment { final String username; final String? displayName; final int createdAt; final int updatedAt; final String? userIdB64; final String? credentialDataB64; const Enrollment({ required this.username, this.displayName, required this.createdAt, required this.updatedAt, this.userIdB64, this.credentialDataB64, }); bool get hasCredential => credentialDataB64 != null; Enrollment copyWith({ String? displayName, int? updatedAt, String? userIdB64, String? credentialDataB64, }) => Enrollment( username: username, displayName: displayName ?? this.displayName, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, userIdB64: userIdB64 ?? this.userIdB64, credentialDataB64: credentialDataB64 ?? this.credentialDataB64, ); Map toJson() => { 'username': username, 'display_name': displayName, 'created_at': createdAt, 'updated_at': updatedAt, 'user_id_b64': userIdB64, 'credential_data_b64': credentialDataB64, }; factory Enrollment.fromJson(Map m) { final username = (m['username'] as String? ?? '').trim(); final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs(); return Enrollment( username: username, displayName: normalizeDisplayName(m['display_name'] as String?), createdAt: createdAt, updatedAt: m['updated_at'] as int? ?? createdAt, userIdB64: m['user_id_b64'] as String?, credentialDataB64: m['credential_data_b64'] as String?, ); } } int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000; // --------------------------------------------------------------------------- // Database // --------------------------------------------------------------------------- class EnrollmentDb { final Map _entries = {}; bool _loaded = false; // Dart isolates are single-threaded so there is no data race on _entries. // We still serialize async disk I/O with a simple future chain. Future? _pending; Future _serialize(Future Function() op) async { final prev = _pending; final next = _doAfter(prev, op); _pending = next; await next; } static Future _doAfter(Future? prev, Future Function() op) async { if (prev != null) { try { await prev; } catch (_) {} } await op(); } // ------------------------------------------------------------------------- // Persistence // ------------------------------------------------------------------------- Future _dbFile() async { final dir = await getApplicationSupportDirectory(); return File('${dir.path}/k_phone_enrollments.json'); } Future _load() async { if (_loaded) return; _loaded = true; try { final f = await _dbFile(); if (!f.existsSync()) return; final raw = jsonDecode(await f.readAsString()) as Map; final users = raw['users'] as List? ?? []; for (final item in users) { final e = Enrollment.fromJson(item as Map); if (e.username.isNotEmpty) _entries[e.username] = e; } } catch (_) { _entries.clear(); } } Future _save() async { final f = await _dbFile(); final users = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username)); await f.writeAsString( const JsonEncoder.withIndent(' ').convert({'users': users.map((e) => e.toJson()).toList()}) + '\n', ); } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- Future ensureLoaded() async { await _serialize(_load); } /// Register a new user. Throws [StateError] if already enrolled. Future register({ required String username, String? displayName, String? userIdB64, String? credentialDataB64, }) async { final canonical = normalizeUsername(username); final pretty = normalizeDisplayName(displayName); final now = _nowSecs(); Enrollment? result; await _serialize(() async { await _load(); if (_entries.containsKey(canonical)) throw StateError('user already enrolled'); final e = Enrollment( username: canonical, displayName: pretty, createdAt: now, updatedAt: now, userIdB64: userIdB64, credentialDataB64: credentialDataB64, ); _entries[canonical] = e; result = e; await _save(); }); return result!; } /// Update display_name (and optionally credential data) for an existing user. /// Throws [StateError] if not found. Future update({ required String username, String? displayName, String? userIdB64, String? credentialDataB64, }) async { final canonical = normalizeUsername(username); final pretty = normalizeDisplayName(displayName); final now = _nowSecs(); Enrollment? result; await _serialize(() async { await _load(); final existing = _entries[canonical]; if (existing == null) throw StateError('user not enrolled'); final updated = existing.copyWith( displayName: pretty, updatedAt: now, userIdB64: userIdB64 ?? existing.userIdB64, credentialDataB64: credentialDataB64 ?? existing.credentialDataB64, ); _entries[canonical] = updated; result = updated; await _save(); }); return result!; } /// Delete a user. Throws [StateError] if not found. Returns deleted entry. Future delete(String username) async { final canonical = normalizeUsername(username); Enrollment? result; await _serialize(() async { await _load(); final existing = _entries.remove(canonical); if (existing == null) throw StateError('user not enrolled'); result = existing; await _save(); }); return result!; } /// Get a single enrollment or null. Future get(String username) async { String canonical; try { canonical = normalizeUsername(username); } catch (_) { return null; } await ensureLoaded(); return _entries[canonical]; } /// List all enrollments sorted by username. Future> list() async { await ensureLoaded(); final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username)); return result; } }