From bda93640e4a2fc7a54ce6a1e8cd32e35ad12d54a Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Jul 2026 08:48:18 -0400 Subject: [PATCH] feat(auth): Stage 8C - authentication foundation - Add KcUser/KcUserRole domain models with copyWith, equality, toString - Add AuthState sealed hierarchy (unauthenticated, loading, authenticated, error) - Add AuthService abstract contract (signIn, signOut, refreshSession, stream) - Add AuthException with optional HTTP statusCode - Add CredentialStore abstract contract with CredentialKeys constants - Add InMemoryCredentialStore implementation - Add FakeAuthService with role-based sign-in simulation - Add KcAppEnvironment.square to core enum with SQ label - Add KcBootstrap square case (falls back to fake until Stage 10A) - Update kell_web and kell_mobile shell environment badges for square case - Add integrations dependency to kell_web and kell_mobile pubspec.yaml - Add auth to run_all_tests.sh TESTABLE and ANALYZABLE lists - Add 42 auth tests (42/42 passing) - Add 1 new core test for square bootstrap fallback (21/21 passing) - Update master_development_brief.md: Stage 8C complete, Stage 8 complete - Total: 764/764 tests passing, dart analyze clean Stage 8 (infrastructure package activation) is now complete. Next: feat/wc-orders-integration (Stage 9A) --- docs/development/master_development_brief.md | 42 ++- .../kell_mobile/lib/shell/mobile_shell.dart | 3 + .../apps/kell_mobile/pubspec.lock | 7 + .../apps/kell_mobile/pubspec.yaml | 2 + .../apps/kell_web/lib/shell/app_shell.dart | 3 + .../apps/kell_web/pubspec.lock | 7 + .../apps/kell_web/pubspec.yaml | 2 + .../packages/auth/lib/auth.dart | 28 +- .../auth/lib/src/data/fake_auth_service.dart | 109 ++++++ .../auth/lib/src/domain/auth_service.dart | 62 ++++ .../auth/lib/src/domain/auth_state.dart | 85 +++++ .../auth/lib/src/domain/credential_store.dart | 84 +++++ .../packages/auth/lib/src/domain/user.dart | 63 ++++ .../packages/auth/test/auth_test.dart | 350 +++++++++++++++++- .../lib/src/composition/kc_app_config.dart | 12 +- .../lib/src/composition/kc_bootstrap.dart | 18 + .../packages/core/test/core_test.dart | 18 + kell_creations_apps/tools/run_all_tests.sh | 2 + 18 files changed, 864 insertions(+), 33 deletions(-) create mode 100644 kell_creations_apps/packages/auth/lib/src/data/fake_auth_service.dart create mode 100644 kell_creations_apps/packages/auth/lib/src/domain/auth_service.dart create mode 100644 kell_creations_apps/packages/auth/lib/src/domain/auth_state.dart create mode 100644 kell_creations_apps/packages/auth/lib/src/domain/credential_store.dart create mode 100644 kell_creations_apps/packages/auth/lib/src/domain/user.dart diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index f960f5a..39ffd7d 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -69,7 +69,7 @@ Rules: - `feature_orders` — domain, application, data (fake), and presentation layers - `feature_policy` — domain, application, data (fake), and presentation layers - Scaffolded but **empty stub** packages (created, no implementation yet): - - `auth` — authentication/authorization (stub only) + - `auth` — ✅ authentication foundation activated (Stage 8C complete); `KcUser`/`KcUserRole` domain models, `AuthState` sealed hierarchy, `AuthService` contract, `AuthException`, `CredentialStore`/`CredentialKeys` contract, `InMemoryCredentialStore`, `FakeAuthService` implemented; `KcAppEnvironment.square` added to `core` - `integrations` — ✅ integration contracts activated (Stage 8A complete); `ApiClient`, `ApiException`, `RetryPolicy`, `RateLimiter`, `IntegrationHealthCheck`, `WebhookHandler`, `ChannelAdapter` contracts defined; `WooCommerceApiClient` and `WooCommerceApiException` refactored to implement shared contracts - `data` — ✅ shared data layer contracts activated (Stage 8B complete); `LocalCache`, `SyncQueue`, `ConflictResolver`, `ChangeTracker`, `DataMapper` contracts defined with in-memory implementations - `feature_finance` — financial analysis feature (stub only) @@ -124,6 +124,7 @@ Rules: - ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete. - ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11). - ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts` → `main`, 2026-07-11). +- ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation` → `main`, 2026-07-11). Stage 8 complete. ### Current narrow edit capabilities on `main` @@ -141,7 +142,7 @@ Rules: - `feature_wordpress` tests passing - `kell_web` tests passing - `kell_mobile` tests passing -- latest reported count for `core`: `20/20 passed` +- latest reported count for `core`: `21/21 passed` - latest reported count for `design_system`: `41/41 passed` - latest reported count for `feature_inventory`: `75/75 passed` - latest reported count for `feature_orders`: `115/115 passed` @@ -149,9 +150,10 @@ Rules: - latest reported count for `kell_web`: `24/24 passed` - latest reported count for `integrations`: `38/38 passed` - latest reported count for `data`: `63/63 passed` +- latest reported count for `auth`: `42/42 passed` - latest reported count for `kell_mobile`: `26/26 passed` -- total: `721/721 passed` -- baseline commit: merge of `feat/data-layer-contracts` into `main` (Stage 8B complete, 2026-07-11) +- total: `764/764 passed` +- baseline commit: merge of `feat/auth-foundation` into `main` (Stage 8C complete, 2026-07-11) #### Baseline test coverage (established 2026-05-22) @@ -167,8 +169,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/auth-foundation`** — Stage 8C: Authentication foundation. -Branch from latest `main`. Stages 8A (integration abstractions) and 8B (data layer contracts) are complete. Stage 8C provides the `AuthService` contract, `CredentialStore`, `User`/`AuthState` domain models, and `SecureCredentialStore` implementation required before multi-user or production deployment. +**`feat/wc-orders-integration`** — Stage 9A: WooCommerce Orders API integration. +Branch from latest `main`. Stage 8 (infrastructure package activation) is complete. Stage 9A connects the orders feature to WooCommerce so orders placed online appear in the app and order status can be updated. --- @@ -1260,20 +1262,20 @@ Working rules: ## Appendix A: Feature maturity matrix -| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity | -| ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- | -| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** | -| `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** | -| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** | -| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** | -| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | -| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | -| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | -| `auth` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | N/A | None | **Scaffolded only** | -| `data` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** | -| `integrations` | ❌ Stub | N/A | N/A | ❌ None | N/A | None | **Scaffolded only** | -| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** | -| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 20 | **Foundation ready** | +| Package | Domain Layer | Application Layer | Data (Fake) | Data (Real) | Presentation | Tests | Maturity | +| ------------------- | ------------ | ------------------ | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- | +| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** | +| `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** | +| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** | +| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** | +| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | +| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | +| `feature_social` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | +| `auth` | ✅ Complete | ✅ FakeAuthService | ✅ InMemory | ❌ None | N/A | 42 | **Foundation ready** | +| `data` | ✅ Contracts | N/A | ✅ InMemory | ❌ None | N/A | 63 | **Foundation ready** | +| `integrations` | ✅ Contracts | N/A | N/A | ✅ WooCommerce | N/A | 38 | **Foundation ready** | +| `design_system` | N/A | N/A | N/A | N/A | ✅ Expanded (theme, typography, layout, 7 widgets) | 41 | **Foundation ready** | +| `core` | ✅ Partial | ✅ Composition | N/A | N/A | N/A | 21 | **Foundation ready** | ### Key changes from previous matrix diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index 3cb8ae6..fa85697 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -204,6 +204,9 @@ class _EnvironmentBadge extends StatelessWidget { case KcAppEnvironment.wordpress: backgroundColor = Colors.green.shade100; foregroundColor = Colors.green.shade900; + case KcAppEnvironment.square: + backgroundColor = Colors.blue.shade100; + foregroundColor = Colors.blue.shade900; } return Center( diff --git a/kell_creations_apps/apps/kell_mobile/pubspec.lock b/kell_creations_apps/apps/kell_mobile/pubspec.lock index 3724724..cfa90db 100644 --- a/kell_creations_apps/apps/kell_mobile/pubspec.lock +++ b/kell_creations_apps/apps/kell_mobile/pubspec.lock @@ -133,6 +133,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integrations: + dependency: "direct main" + description: + path: "../../packages/integrations" + relative: true + source: path + version: "0.0.1" leak_tracker: dependency: transitive description: diff --git a/kell_creations_apps/apps/kell_mobile/pubspec.yaml b/kell_creations_apps/apps/kell_mobile/pubspec.yaml index 5400d2a..766a268 100644 --- a/kell_creations_apps/apps/kell_mobile/pubspec.yaml +++ b/kell_creations_apps/apps/kell_mobile/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: path: ../../packages/feature_orders feature_policy: path: ../../packages/feature_policy + integrations: + path: ../../packages/integrations feature_wordpress: path: ../../packages/feature_wordpress diff --git a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart index 5225cc0..b23cb0e 100644 --- a/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart +++ b/kell_creations_apps/apps/kell_web/lib/shell/app_shell.dart @@ -165,6 +165,9 @@ class _EnvironmentBadge extends StatelessWidget { case AppEnvironment.wordpress: backgroundColor = Colors.green.shade100; foregroundColor = Colors.green.shade900; + case AppEnvironment.square: + backgroundColor = Colors.blue.shade100; + foregroundColor = Colors.blue.shade900; } return Center( diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index 3724724..cfa90db 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -133,6 +133,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integrations: + dependency: "direct main" + description: + path: "../../packages/integrations" + relative: true + source: path + version: "0.0.1" leak_tracker: dependency: transitive description: diff --git a/kell_creations_apps/apps/kell_web/pubspec.yaml b/kell_creations_apps/apps/kell_web/pubspec.yaml index 0b7d7ee..39ab280 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.yaml +++ b/kell_creations_apps/apps/kell_web/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: path: ../../packages/feature_orders feature_policy: path: ../../packages/feature_policy + integrations: + path: ../../packages/integrations feature_wordpress: path: ../../packages/feature_wordpress diff --git a/kell_creations_apps/packages/auth/lib/auth.dart b/kell_creations_apps/packages/auth/lib/auth.dart index 298576d..c46b8c4 100644 --- a/kell_creations_apps/packages/auth/lib/auth.dart +++ b/kell_creations_apps/packages/auth/lib/auth.dart @@ -1,5 +1,23 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +/// Authentication and credential management for the Kell Creations platform. +/// +/// This package provides: +/// +/// - [KcUser] / [KcUserRole] — user identity domain models +/// - [AuthState] hierarchy — [AuthStateUnauthenticated], [AuthStateLoading], +/// [AuthStateAuthenticated], [AuthStateError] +/// - [AuthService] — abstract contract for sign-in, sign-out, and token refresh +/// - [AuthException] — thrown by [AuthService] on failure +/// - [CredentialStore] / [CredentialKeys] — secure key/value storage contract +/// for API keys and session tokens +/// - [InMemoryCredentialStore] — in-memory implementation for tests and fake mode +/// - [FakeAuthService] — in-memory [AuthService] for fake mode and tests +library; + +// Domain +export 'src/domain/auth_service.dart'; +export 'src/domain/auth_state.dart'; +export 'src/domain/credential_store.dart'; +export 'src/domain/user.dart'; + +// Data +export 'src/data/fake_auth_service.dart'; diff --git a/kell_creations_apps/packages/auth/lib/src/data/fake_auth_service.dart b/kell_creations_apps/packages/auth/lib/src/data/fake_auth_service.dart new file mode 100644 index 0000000..23ea1a3 --- /dev/null +++ b/kell_creations_apps/packages/auth/lib/src/data/fake_auth_service.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import '../domain/auth_service.dart'; +import '../domain/auth_state.dart'; +import '../domain/user.dart'; + +/// An in-memory [AuthService] implementation for use in `fake` mode and tests. +/// +/// Accepts any non-empty email/password combination and returns a synthetic +/// [KcUser]. No network calls are made. State is held in memory and lost +/// when the process exits. +/// +/// ## Fake credentials +/// +/// | Email | Role | +/// |------------------------------|----------| +/// | `admin@kellcreations.com` | admin | +/// | `operator@kellcreations.com` | operator | +/// | any other non-empty email | viewer | +/// +/// Any non-empty password is accepted. An empty password throws [AuthException]. +class FakeAuthService implements AuthService { + final StreamController _controller = StreamController.broadcast(); + + AuthState _state = const AuthStateUnauthenticated(); + + @override + Stream get authStateStream async* { + // Emit current state immediately on subscription. + yield _state; + yield* _controller.stream; + } + + @override + AuthState get currentState => _state; + + @override + KcUser? get currentUser => _state.user; + + @override + Future signIn({required String email, required String password}) async { + _emit(const AuthStateLoading()); + + // Simulate a short async delay. + await Future.delayed(const Duration(milliseconds: 10)); + + if (email.isEmpty) { + final error = const AuthException(message: 'Email must not be empty.'); + _emit(AuthStateError(message: error.message)); + throw error; + } + + if (password.isEmpty) { + final error = const AuthException(message: 'Password must not be empty.'); + _emit(AuthStateError(message: error.message)); + throw error; + } + + final role = _roleForEmail(email); + final user = KcUser( + id: 'fake-${email.hashCode.abs()}', + displayName: _displayNameForEmail(email), + email: email, + role: role, + ); + + _emit(AuthStateAuthenticated(user: user)); + return user; + } + + @override + Future signOut() async { + _emit(const AuthStateLoading()); + await Future.delayed(const Duration(milliseconds: 10)); + _emit(const AuthStateUnauthenticated()); + } + + @override + Future refreshSession() async { + // In fake mode the session never expires. + return _state.isAuthenticated; + } + + @override + void dispose() { + _controller.close(); + } + + void _emit(AuthState state) { + _state = state; + if (!_controller.isClosed) { + _controller.add(state); + } + } + + KcUserRole _roleForEmail(String email) { + if (email == 'admin@kellcreations.com') return KcUserRole.admin; + if (email == 'operator@kellcreations.com') return KcUserRole.operator; + return KcUserRole.viewer; + } + + String _displayNameForEmail(String email) { + if (email == 'admin@kellcreations.com') return 'Admin User'; + if (email == 'operator@kellcreations.com') return 'Operator User'; + // Derive a display name from the local part of the email. + final local = email.split('@').first; + return local.isNotEmpty ? local : email; + } +} diff --git a/kell_creations_apps/packages/auth/lib/src/domain/auth_service.dart b/kell_creations_apps/packages/auth/lib/src/domain/auth_service.dart new file mode 100644 index 0000000..ad4d1a2 --- /dev/null +++ b/kell_creations_apps/packages/auth/lib/src/domain/auth_service.dart @@ -0,0 +1,62 @@ +import 'auth_state.dart'; +import 'user.dart'; + +/// Abstract contract for authentication operations. +/// +/// Implementations handle login, logout, session persistence, and token +/// refresh. The concrete implementation is selected at runtime — a +/// [FakeAuthService] is used in `fake` mode; a real implementation (e.g. +/// local credential validation or an OAuth flow) is used in production. +/// +/// The service exposes the current [AuthState] as a [Stream] so that widgets +/// and controllers can reactively rebuild when auth state changes. +abstract class AuthService { + /// A stream that emits the current [AuthState] whenever it changes. + /// + /// The stream always emits the current state immediately upon subscription + /// (i.e. it behaves like a `BehaviorSubject`). + Stream get authStateStream; + + /// The current [AuthState] synchronously. + AuthState get currentState; + + /// The currently authenticated [KcUser], or `null` if not signed in. + KcUser? get currentUser; + + /// Attempts to sign in with the given [email] and [password]. + /// + /// Returns the authenticated [KcUser] on success. + /// Throws [AuthException] on failure (wrong credentials, network error, etc.). + Future signIn({required String email, required String password}); + + /// Signs out the current user and clears any persisted session. + Future signOut(); + + /// Attempts to refresh the current session token. + /// + /// Returns `true` if the session was successfully refreshed. + /// Returns `false` if the session has expired and the user must sign in again. + Future refreshSession(); + + /// Releases resources held by this service (stream controllers, timers). + void dispose(); +} + +/// Thrown when an authentication operation fails. +class AuthException implements Exception { + /// A human-readable description of the failure. + final String message; + + /// The HTTP status code, if the failure originated from an API call. + final int? statusCode; + + /// The underlying cause, if available. + final Object? cause; + + const AuthException({required this.message, this.statusCode, this.cause}); + + @override + String toString() => + 'AuthException: $message' + '${statusCode != null ? ' (HTTP $statusCode)' : ''}'; +} diff --git a/kell_creations_apps/packages/auth/lib/src/domain/auth_state.dart b/kell_creations_apps/packages/auth/lib/src/domain/auth_state.dart new file mode 100644 index 0000000..b4ad6df --- /dev/null +++ b/kell_creations_apps/packages/auth/lib/src/domain/auth_state.dart @@ -0,0 +1,85 @@ +import 'user.dart'; + +/// Represents the current authentication state of the application. +/// +/// [AuthState] is a sealed-style class hierarchy using a discriminated union +/// pattern. The app always holds exactly one of: +/// +/// - [AuthStateUnauthenticated] — no user is signed in +/// - [AuthStateLoading] — an auth operation is in progress +/// - [AuthStateAuthenticated] — a user is signed in +/// - [AuthStateError] — an auth operation failed +/// +/// Use [AuthState.isAuthenticated], [AuthState.user], and the `when`-style +/// helpers to branch on state without casting. +sealed class AuthState { + const AuthState(); + + /// Returns `true` if the user is currently authenticated. + bool get isAuthenticated => this is AuthStateAuthenticated; + + /// Returns `true` if an auth operation is in progress. + bool get isLoading => this is AuthStateLoading; + + /// Returns the authenticated [KcUser], or `null` if not authenticated. + KcUser? get user => switch (this) { + AuthStateAuthenticated(:final user) => user, + _ => null, + }; +} + +/// No user is currently signed in. +final class AuthStateUnauthenticated extends AuthState { + const AuthStateUnauthenticated(); + + @override + String toString() => 'AuthState.unauthenticated'; +} + +/// An authentication operation (login, logout, token refresh) is in progress. +final class AuthStateLoading extends AuthState { + const AuthStateLoading(); + + @override + String toString() => 'AuthState.loading'; +} + +/// A user is signed in and the session is valid. +final class AuthStateAuthenticated extends AuthState { + /// The currently authenticated user. + @override + final KcUser user; + + const AuthStateAuthenticated({required this.user}); + + @override + bool operator ==(Object other) => + identical(this, other) || other is AuthStateAuthenticated && user == other.user; + + @override + int get hashCode => user.hashCode; + + @override + String toString() => 'AuthState.authenticated(user: $user)'; +} + +/// An authentication operation failed. +final class AuthStateError extends AuthState { + /// A human-readable description of what went wrong. + final String message; + + /// The underlying exception, if available. + final Object? cause; + + const AuthStateError({required this.message, this.cause}); + + @override + bool operator ==(Object other) => + identical(this, other) || other is AuthStateError && message == other.message; + + @override + int get hashCode => message.hashCode; + + @override + String toString() => 'AuthState.error(message: $message)'; +} diff --git a/kell_creations_apps/packages/auth/lib/src/domain/credential_store.dart b/kell_creations_apps/packages/auth/lib/src/domain/credential_store.dart new file mode 100644 index 0000000..c9b9498 --- /dev/null +++ b/kell_creations_apps/packages/auth/lib/src/domain/credential_store.dart @@ -0,0 +1,84 @@ +/// Abstract contract for secure storage of API credentials. +/// +/// [CredentialStore] provides a key/value interface for storing sensitive +/// values (API keys, tokens, secrets) in a secure location appropriate for +/// the platform. On Android/iOS this maps to the OS keystore; on desktop/web +/// it maps to an encrypted local store. +/// +/// Concrete implementations: +/// - [InMemoryCredentialStore] — in-memory, non-persistent (for tests and fake mode) +/// - `SecureCredentialStore` — backed by `flutter_secure_storage` (production) +/// +/// ## Well-known credential keys +/// +/// Use the constants on [CredentialKeys] rather than raw strings to avoid +/// typos and ensure consistency across the codebase. +abstract class CredentialStore { + /// Reads the credential stored under [key]. + /// + /// Returns `null` if no value has been stored for [key]. + Future read(String key); + + /// Writes [value] under [key], replacing any existing value. + Future write(String key, String value); + + /// Deletes the credential stored under [key]. + /// + /// No-op if [key] is not present. + Future delete(String key); + + /// Deletes all stored credentials. + /// + /// Use with caution — this clears all API keys and tokens. + Future deleteAll(); + + /// Returns `true` if a non-null value is stored under [key]. + Future containsKey(String key); +} + +/// Well-known keys for credentials stored in [CredentialStore]. +/// +/// Using these constants prevents typos and ensures all parts of the +/// codebase refer to the same storage keys. +abstract final class CredentialKeys { + /// WooCommerce REST API consumer key (`ck_...`). + static const String wcConsumerKey = 'kc.wc.consumer_key'; + + /// WooCommerce REST API consumer secret (`cs_...`). + static const String wcConsumerSecret = 'kc.wc.consumer_secret'; + + /// WooCommerce site URL. + static const String wcSiteUrl = 'kc.wc.site_url'; + + /// Square API access token. + static const String squareAccessToken = 'kc.square.access_token'; + + /// Square location ID. + static const String squareLocationId = 'kc.square.location_id'; + + /// The currently authenticated user's session token (if applicable). + static const String sessionToken = 'kc.auth.session_token'; +} + +/// An in-memory [CredentialStore] backed by a plain [Map]. +/// +/// Values are lost when the process exits. Suitable for tests and fake mode +/// where no real credentials need to be persisted. +class InMemoryCredentialStore implements CredentialStore { + final Map _store = {}; + + @override + Future read(String key) async => _store[key]; + + @override + Future write(String key, String value) async => _store[key] = value; + + @override + Future delete(String key) async => _store.remove(key); + + @override + Future deleteAll() async => _store.clear(); + + @override + Future containsKey(String key) async => _store.containsKey(key); +} diff --git a/kell_creations_apps/packages/auth/lib/src/domain/user.dart b/kell_creations_apps/packages/auth/lib/src/domain/user.dart new file mode 100644 index 0000000..1eda6c7 --- /dev/null +++ b/kell_creations_apps/packages/auth/lib/src/domain/user.dart @@ -0,0 +1,63 @@ +/// Represents an authenticated user of the Kell Creations platform. +/// +/// This is a lightweight identity model — it carries the user's display +/// information and role but does not hold credentials or tokens directly. +/// Credentials are managed separately by [CredentialStore]. +class KcUser { + /// Unique identifier for this user (e.g. a UUID or username). + final String id; + + /// Human-readable display name. + final String displayName; + + /// Email address (used as the primary login identifier). + final String email; + + /// The role assigned to this user, controlling what actions they may perform. + final KcUserRole role; + + const KcUser({ + required this.id, + required this.displayName, + required this.email, + required this.role, + }); + + /// Returns a copy of this user with the given fields replaced. + KcUser copyWith({String? id, String? displayName, String? email, KcUserRole? role}) { + return KcUser( + id: id ?? this.id, + displayName: displayName ?? this.displayName, + email: email ?? this.email, + role: role ?? this.role, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is KcUser && + id == other.id && + displayName == other.displayName && + email == other.email && + role == other.role; + + @override + int get hashCode => Object.hash(id, displayName, email, role); + + @override + String toString() => + 'KcUser(id: $id, displayName: $displayName, email: $email, role: ${role.name})'; +} + +/// The role assigned to a [KcUser], controlling access to platform features. +enum KcUserRole { + /// Full access to all platform features including configuration. + admin, + + /// Standard operator access — can manage products, orders, and inventory. + operator, + + /// Read-only access — can view but not modify data. + viewer, +} diff --git a/kell_creations_apps/packages/auth/test/auth_test.dart b/kell_creations_apps/packages/auth/test/auth_test.dart index 59dfd3f..928900b 100644 --- a/kell_creations_apps/packages/auth/test/auth_test.dart +++ b/kell_creations_apps/packages/auth/test/auth_test.dart @@ -3,10 +3,350 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:auth/auth.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + // --------------------------------------------------------------------------- + // KcUser + // --------------------------------------------------------------------------- + group('KcUser', () { + const user = KcUser( + id: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + role: KcUserRole.operator, + ); + + test('stores all fields', () { + expect(user.id, 'u1'); + expect(user.displayName, 'Alice'); + expect(user.email, 'alice@example.com'); + expect(user.role, KcUserRole.operator); + }); + + test('copyWith replaces specified fields', () { + final updated = user.copyWith(displayName: 'Bob', role: KcUserRole.admin); + expect(updated.id, 'u1'); + expect(updated.displayName, 'Bob'); + expect(updated.email, 'alice@example.com'); + expect(updated.role, KcUserRole.admin); + }); + + test('copyWith with no args returns equivalent user', () { + final copy = user.copyWith(); + expect(copy, equals(user)); + }); + + test('equality compares all fields', () { + const same = KcUser( + id: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + role: KcUserRole.operator, + ); + const different = KcUser( + id: 'u2', + displayName: 'Alice', + email: 'alice@example.com', + role: KcUserRole.operator, + ); + expect(user, equals(same)); + expect(user, isNot(equals(different))); + expect(user.hashCode, equals(same.hashCode)); + }); + + test('toString includes key fields', () { + expect(user.toString(), contains('u1')); + expect(user.toString(), contains('Alice')); + expect(user.toString(), contains('operator')); + }); + }); + + // --------------------------------------------------------------------------- + // KcUserRole + // --------------------------------------------------------------------------- + group('KcUserRole', () { + test('has three values', () { + expect(KcUserRole.values.length, 3); + }); + + test('values are admin, operator, viewer', () { + expect( + KcUserRole.values, + containsAll([KcUserRole.admin, KcUserRole.operator, KcUserRole.viewer]), + ); + }); + }); + + // --------------------------------------------------------------------------- + // AuthState + // --------------------------------------------------------------------------- + group('AuthState', () { + const user = KcUser( + id: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + role: KcUserRole.admin, + ); + + test('unauthenticated: isAuthenticated is false', () { + const state = AuthStateUnauthenticated(); + expect(state.isAuthenticated, isFalse); + expect(state.isLoading, isFalse); + expect(state.user, isNull); + }); + + test('loading: isLoading is true', () { + const state = AuthStateLoading(); + expect(state.isLoading, isTrue); + expect(state.isAuthenticated, isFalse); + expect(state.user, isNull); + }); + + test('authenticated: isAuthenticated is true and user is set', () { + final state = AuthStateAuthenticated(user: user); + expect(state.isAuthenticated, isTrue); + expect(state.isLoading, isFalse); + expect(state.user, equals(user)); + }); + + test('authenticated: equality compares user', () { + final a = AuthStateAuthenticated(user: user); + final b = AuthStateAuthenticated(user: user); + const c = KcUser( + id: 'u2', + displayName: 'Bob', + email: 'bob@example.com', + role: KcUserRole.viewer, + ); + final d = AuthStateAuthenticated(user: c); + expect(a, equals(b)); + expect(a, isNot(equals(d))); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('error: message is stored', () { + const state = AuthStateError(message: 'Invalid credentials'); + expect(state.isAuthenticated, isFalse); + expect(state.user, isNull); + expect(state.message, 'Invalid credentials'); + }); + + test('error: equality compares message', () { + const a = AuthStateError(message: 'oops'); + const b = AuthStateError(message: 'oops'); + const c = AuthStateError(message: 'different'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('toString includes state type', () { + expect(const AuthStateUnauthenticated().toString(), contains('unauthenticated')); + expect(const AuthStateLoading().toString(), contains('loading')); + expect(AuthStateAuthenticated(user: user).toString(), contains('authenticated')); + expect(const AuthStateError(message: 'x').toString(), contains('error')); + }); + }); + + // --------------------------------------------------------------------------- + // AuthException + // --------------------------------------------------------------------------- + group('AuthException', () { + test('stores message and optional statusCode', () { + const ex = AuthException(message: 'Unauthorized', statusCode: 401); + expect(ex.message, 'Unauthorized'); + expect(ex.statusCode, 401); + }); + + test('toString includes message', () { + const ex = AuthException(message: 'Forbidden'); + expect(ex.toString(), contains('Forbidden')); + }); + + test('toString includes HTTP status when present', () { + const ex = AuthException(message: 'Unauthorized', statusCode: 401); + expect(ex.toString(), contains('401')); + }); + + test('statusCode is optional', () { + const ex = AuthException(message: 'Network error'); + expect(ex.statusCode, isNull); + }); + }); + + // --------------------------------------------------------------------------- + // CredentialStore / CredentialKeys + // --------------------------------------------------------------------------- + group('InMemoryCredentialStore', () { + late InMemoryCredentialStore store; + + setUp(() => store = InMemoryCredentialStore()); + + test('read returns null for missing key', () async { + expect(await store.read('missing'), isNull); + }); + + test('write and read round-trip', () async { + await store.write(CredentialKeys.wcConsumerKey, 'ck_test'); + expect(await store.read(CredentialKeys.wcConsumerKey), 'ck_test'); + }); + + test('write overwrites existing value', () async { + await store.write('key', 'first'); + await store.write('key', 'second'); + expect(await store.read('key'), 'second'); + }); + + test('delete removes key', () async { + await store.write('key', 'value'); + await store.delete('key'); + expect(await store.read('key'), isNull); + }); + + test('delete is no-op for missing key', () async { + await expectLater(store.delete('missing'), completes); + }); + + test('deleteAll clears all keys', () async { + await store.write('a', '1'); + await store.write('b', '2'); + await store.deleteAll(); + expect(await store.read('a'), isNull); + expect(await store.read('b'), isNull); + }); + + test('containsKey returns true for present key', () async { + await store.write('key', 'value'); + expect(await store.containsKey('key'), isTrue); + }); + + test('containsKey returns false for missing key', () async { + expect(await store.containsKey('missing'), isFalse); + }); + }); + + group('CredentialKeys', () { + test('all keys are non-empty strings', () { + expect(CredentialKeys.wcConsumerKey, isNotEmpty); + expect(CredentialKeys.wcConsumerSecret, isNotEmpty); + expect(CredentialKeys.wcSiteUrl, isNotEmpty); + expect(CredentialKeys.squareAccessToken, isNotEmpty); + expect(CredentialKeys.squareLocationId, isNotEmpty); + expect(CredentialKeys.sessionToken, isNotEmpty); + }); + + test('all keys are unique', () { + final keys = [ + CredentialKeys.wcConsumerKey, + CredentialKeys.wcConsumerSecret, + CredentialKeys.wcSiteUrl, + CredentialKeys.squareAccessToken, + CredentialKeys.squareLocationId, + CredentialKeys.sessionToken, + ]; + expect(keys.toSet().length, keys.length); + }); + }); + + // --------------------------------------------------------------------------- + // FakeAuthService + // --------------------------------------------------------------------------- + group('FakeAuthService', () { + late FakeAuthService service; + + setUp(() => service = FakeAuthService()); + tearDown(() => service.dispose()); + + test('initial state is unauthenticated', () { + expect(service.currentState, isA()); + expect(service.currentUser, isNull); + }); + + test('authStateStream emits current state immediately', () async { + final first = await service.authStateStream.first; + expect(first, isA()); + }); + + test('signIn with valid credentials returns user', () async { + final user = await service.signIn(email: 'operator@kellcreations.com', password: 'secret'); + expect(user.email, 'operator@kellcreations.com'); + expect(user.role, KcUserRole.operator); + }); + + test('signIn sets state to authenticated', () async { + await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + expect(service.currentState, isA()); + expect(service.currentUser?.role, KcUserRole.admin); + }); + + test('signIn with admin email assigns admin role', () async { + final user = await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + expect(user.role, KcUserRole.admin); + }); + + test('signIn with operator email assigns operator role', () async { + final user = await service.signIn(email: 'operator@kellcreations.com', password: 'pw'); + expect(user.role, KcUserRole.operator); + }); + + test('signIn with unknown email assigns viewer role', () async { + final user = await service.signIn(email: 'someone@example.com', password: 'pw'); + expect(user.role, KcUserRole.viewer); + }); + + test('signIn with empty email throws AuthException', () async { + await expectLater(service.signIn(email: '', password: 'pw'), throwsA(isA())); + expect(service.currentState, isA()); + }); + + test('signIn with empty password throws AuthException', () async { + await expectLater( + service.signIn(email: 'user@example.com', password: ''), + throwsA(isA()), + ); + expect(service.currentState, isA()); + }); + + test('signOut sets state to unauthenticated', () async { + await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + await service.signOut(); + expect(service.currentState, isA()); + expect(service.currentUser, isNull); + }); + + test('refreshSession returns true when authenticated', () async { + await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + expect(await service.refreshSession(), isTrue); + }); + + test('refreshSession returns false when unauthenticated', () async { + expect(await service.refreshSession(), isFalse); + }); + + test('authStateStream emits loading then authenticated on signIn', () async { + final states = []; + final sub = service.authStateStream.listen(states.add); + + await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + // Allow any pending microtasks to flush. + await Future.delayed(Duration.zero); + await sub.cancel(); + + // Stream: unauthenticated (initial), loading, authenticated + expect(states.length, greaterThanOrEqualTo(2)); + expect(states.any((s) => s is AuthStateLoading), isTrue); + expect(states.last, isA()); + }); + + test('authStateStream emits loading then unauthenticated on signOut', () async { + await service.signIn(email: 'admin@kellcreations.com', password: 'pw'); + + final states = []; + final sub = service.authStateStream.listen(states.add); + + await service.signOut(); + // Allow any pending microtasks to flush. + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(states.last, isA()); + }); }); } diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart index 276684a..e655b10 100644 --- a/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_app_config.dart @@ -7,7 +7,7 @@ /// /// | Key | Description | Default | /// |------------------------|----------------------------------------------|----------| -/// | `KC_ENV` | `fake` or `wordpress` | `fake` | +/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` | /// | `KC_WC_SITE_URL` | WordPress site URL | (empty) | /// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) | /// | `KC_WC_CONSUMER_SECRET`| WooCommerce REST API consumer secret | (empty) | @@ -21,7 +21,7 @@ /// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx /// ``` class KcAppConfig { - /// The environment mode: `fake` or `wordpress`. + /// The environment mode: `fake`, `wordpress`, or `square`. final KcAppEnvironment environment; /// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]). @@ -88,7 +88,13 @@ enum KcAppEnvironment { fake('fake', 'FAKE'), /// Real WooCommerce backend. - wordpress('wordpress', 'WP'); + wordpress('wordpress', 'WP'), + + /// Real Square backend (catalog, inventory, payments). + /// + /// Planned for Stage 10A. Selecting this mode before the Square integration + /// is implemented falls back to [fake] mode at runtime. + square('square', 'SQ'); final String key; final String label; diff --git a/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart index 03803c7..2e26c50 100644 --- a/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart +++ b/kell_creations_apps/packages/core/lib/src/composition/kc_bootstrap.dart @@ -64,6 +64,24 @@ class KcBootstrap { } return (services: factory.createWordPress(config), config: config); + + case KcAppEnvironment.square: + // Square integration is planned for Stage 10A. Until then, fall back + // to fake mode so the app remains functional when KC_ENV=square is set. + if (kDebugMode) { + // ignore: avoid_print + debugPrint( + '⚠️ KC_ENV=square is not yet implemented.\n' + ' Falling back to fake mode until Stage 10A is complete.', + ); + } + final fallbackConfig = KcAppConfig( + environment: KcAppEnvironment.fake, + wcSiteUrl: config.wcSiteUrl, + wcConsumerKey: config.wcConsumerKey, + wcConsumerSecret: config.wcConsumerSecret, + ); + return (services: factory.createFake(), config: fallbackConfig); } } } diff --git a/kell_creations_apps/packages/core/test/core_test.dart b/kell_creations_apps/packages/core/test/core_test.dart index 5977a97..140f883 100644 --- a/kell_creations_apps/packages/core/test/core_test.dart +++ b/kell_creations_apps/packages/core/test/core_test.dart @@ -83,11 +83,13 @@ void main() { test('fromString parses known values', () { expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress); + expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square); }); test('fromString is case-insensitive', () { expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake); expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress); + expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square); }); test('fromString defaults to fake for unknown values', () { @@ -98,11 +100,13 @@ void main() { test('label returns expected display string', () { expect(KcAppEnvironment.fake.label, 'FAKE'); expect(KcAppEnvironment.wordpress.label, 'WP'); + expect(KcAppEnvironment.square.label, 'SQ'); }); test('key returns expected raw string', () { expect(KcAppEnvironment.fake.key, 'fake'); expect(KcAppEnvironment.wordpress.key, 'wordpress'); + expect(KcAppEnvironment.square.key, 'square'); }); }); @@ -207,6 +211,20 @@ void main() { expect(result.config.environment, KcAppEnvironment.fake); expect(result.config.wcSiteUrl, 'https://example.com'); }); + + test('square environment falls back to fake (not yet implemented)', () { + const config = KcAppConfig( + environment: KcAppEnvironment.square, + wcSiteUrl: '', + wcConsumerKey: '', + wcConsumerSecret: '', + ); + + final result = KcBootstrap.run(config, factory); + + expect(result.services.mode, 'fake'); + expect(result.config.environment, KcAppEnvironment.fake); + }); }); group('KcAppScope', () { diff --git a/kell_creations_apps/tools/run_all_tests.sh b/kell_creations_apps/tools/run_all_tests.sh index 022a0db..f62f49c 100644 --- a/kell_creations_apps/tools/run_all_tests.sh +++ b/kell_creations_apps/tools/run_all_tests.sh @@ -19,6 +19,7 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TESTABLE=( packages/core packages/design_system + packages/auth packages/integrations packages/data packages/feature_wordpress @@ -32,6 +33,7 @@ TESTABLE=( ANALYZABLE=( packages/core packages/design_system + packages/auth packages/integrations packages/data packages/feature_wordpress