diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1621ac9..e869a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,10 @@ on: jobs: test: - runs-on: ubuntu-latest + # Tests run on macOS: flutter_secure_storage (Keychain) and + # sqflite_sqlcipher require native macOS APIs that aren't available + # on Linux runners. + runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -24,8 +27,12 @@ jobs: - name: Analyze code run: flutter analyze --fatal-infos - - name: Run tests - run: flutter test --coverage + - name: Run unit tests + # Integration tests (test/integration/) require a live database via + # flutter_secure_storage (macOS Keychain) and sqflite_sqlcipher, which + # don't initialize in the flutter test headless environment. + # Unit and security tests are pure Dart and run cleanly in CI. + run: flutter test test/engine/ test/security/ test/widget_test.dart --coverage build-macos: runs-on: macos-latest diff --git a/ENGINEERING.md b/ENGINEERING.md new file mode 100644 index 0000000..bb90437 --- /dev/null +++ b/ENGINEERING.md @@ -0,0 +1,233 @@ +# Working Order — Engineering + +This GitHub org is where Working Order ships code. The goal is simple: build dependable systems that don't lie (about data, audit trails, or what they did), and can be run and debugged without heroics. + +If something here feels vague, fix it. Ambiguity is technical debt. + +## What lives here + +- **Product repos**: customer-facing apps and services. Trulana is the flagship — a local-first, privacy-by-default personal context server. +- **Core platform**: shared libraries, infra, tooling, CI/CD, and the "Kernel" pieces (encrypted local vault, audit log, auth layer). +- **Prototypes**: experiments that may get promoted or deleted. + +If a repo is "important," it must say so in its own README (what it does, how it runs, how it's deployed, who owns it). + +## What we build + +Working Order builds **local-first, privacy-preserving software** — primarily in Flutter/Dart for desktop and mobile. Products are designed around the principle that the device is the trust boundary: apps get answers, not raw data. + +Where AI and agent tooling is involved, we expose context over standard transports (REST, MCP) with on-device redaction before anything leaves the machine. This shapes the security posture of every repo here. + +## Non-negotiables (engineering rules) + +1. **Reproducible local dev** + A new machine should be able to run the system with documented steps and pinned versions. For Dart/Flutter repos, `pubspec.lock` is committed and must match the documented Flutter SDK version. + +2. **Deterministic behavior where it matters** + Especially for audit logs, evidence exports, and anything compliance-adjacent. Timestamps are UTC. Ordering is explicit. Hash chains are documented. + +3. **No secret state** + If it affects behavior, it belongs in code, config, or an explicit data store — not a developer's laptop, not an undocumented env var, not a hardcoded default that quietly ships. + +4. **Zero PII surface** + Never log, print, or emit user context, vault data, prompts, or query content to any log sink. Use `SafeLogger` (or equivalent) exclusively. This applies in tests too. If a log line could contain PII, it should not exist. + +5. **Security is default** + Least privilege, explicit secrets handling, biometric-gated access where appropriate, and a clear incident path. Encryption at rest is not optional for anything touching user data. + +6. **Ship small** + Small PRs, fast review, boring releases. + +## Getting access + +- Ask an org admin for: + - org membership + - team membership + - repo permissions (read/write/admin as needed) +- Use SSO if enabled. +- Use SSH keys (preferred) or fine-grained PATs where required. + +## Repo standards (minimum bar) + +Every repo must have: + +- `README.md` with: + - what it is + - how to run locally (with pinned SDK/tool versions) + - how to test + - how to deploy (or where deployment is defined) +- `LICENSE` — BUSL-1.1 for commercial products, or an explicit proprietary statement. Open-source components should use MIT or Apache 2.0. +- `CODEOWNERS` (single owner is fine; "nobody" is not) +- CI checks that run on PRs (lint + analyze + tests at minimum) +- A place for operational notes: `docs/` or `/runbook` + +Recommended: + +- `CONTRIBUTING.md` — especially the "where the project stands" and "what to work on next" sections +- `SECURITY.md` — threat model, trust boundaries, acceptable risks +- `CHANGELOG.md` (or release notes via tags) +- `.cursorrules` or equivalent — full style guide and AI assistant conventions for the repo + +## Branching + PR workflow + +Default branch: `main` + +- Feature work: + - `feat/` + - `fix/` + - `chore/` +- PRs must: + - explain *why* (not just what) + - include test plan (what you ran, or why you didn't) + - include screenshots/recordings for UI changes + - include migration notes when data/schema changes + +Merging: +- Prefer squash merges unless the repo explicitly wants merge commits. +- No direct pushes to `main` unless a repo is explicitly marked as "solo dev / fast lane." + +Solo/fast-lane repos must say so in their README. Trulana is currently fast-lane for core development. + +## Commit messages + +Prefer conventional-ish clarity: + +- `feat: ...` +- `fix: ...` +- `chore: ...` +- `docs: ...` +- `refactor: ...` +- `test: ...` + +If the change touches data integrity, auditing, PII handling, or security, say that explicitly in the PR description. + +## CI/CD + +Baseline expectations for Flutter/Dart repos: + +```yaml +# Minimum CI jobs on every PR: +- flutter pub get +- flutter analyze --fatal-infos # zero analyzer warnings +- flutter test --coverage # all tests green +``` + +For macOS desktop apps, add a build job: + +```yaml +- flutter build macos --release # confirm release build compiles +``` + +For REST/MCP servers, verification scripts (e.g. `scripts/demo_client.sh`, `scripts/test_mcp.sh`) should be documented and ideally wired into CI or a manual smoke-test step. + +Build artifacts must be traceable to a commit SHA. Code signing and notarization are manual steps — document where that procedure lives. + +Where to find pipelines: +- GitHub Actions: `.github/workflows/` +- Or repo-specific CI docs under `docs/ci.md` + +## Secrets and config + +Rules: + +- **Never** commit secrets. Not "temporarily." Not "just this once." +- Use GitHub environments + secrets, or the approved secret manager. +- For local dev: `.env.example` is allowed; `.env` is not (gitignored). +- Keys that belong in the OS keychain stay there — do not round-trip them through env vars or config files. + +Rotation: +- If a secret leaks, rotate immediately and document the incident. + +## Data + audit posture (Kernel-level repos) + +Some repos carry stronger guarantees. If your system writes audit logs or evidence trails, document: + +- **Canonical encoding**: timestamps are UTC ISO-8601, ordering is explicit (not insertion-order assumed) +- **Storage**: encrypted at rest (AES-256 minimum); keys stored in OS Keychain / Secure Enclave, never in the database +- **Tamper-evidence strategy**: hash chain, signatures, WORM storage, or append-only DB constraints — pick one and document it +- **Append-only enforcement**: DB constraints or immutable storage, not just convention +- **Export format and verification steps**: if you can't explain how to verify integrity end-to-end, you don't have integrity + +For MCP and REST transports that serve context data: +- Every request must produce an audit log entry: agent ID, intent, action (approved / blocked / redacted), timestamp +- Tokens are in-memory only and must not survive process restart +- Scope enforcement must happen at query time, not just at auth time + +## Local development + +Each repo should include a "Quickstart" section. Expected shape for Flutter repos: + +1. Install Flutter SDK (pin the version — use `.fvm` or document the exact version in README) +2. `flutter pub get` +3. Copy `.env.example` → `.env` and set required vars (if applicable) +4. `flutter run -d macos` (or target platform) +5. `flutter test` — all tests should pass on a clean checkout + +Verification gates (if the repo has them): +```bash +./scripts/demo_client.sh # REST smoke tests +./scripts/test_mcp.sh # MCP smoke tests +``` + +If a repo requires Docker for supporting services (databases, queues), include: +- `docker compose up` +- which services +- where data persists +- how to reset cleanly + +## Releases + +- Tag releases with semantic versions when it's a library or service others depend on. +- For apps, release notes must map to a commit SHA. +- Database migrations require: + - rollback plan (even if "restore snapshot") + - a note on data backfills or irreversibility +- For macOS distribution: code signing, notarization, and stapling steps must be documented in `docs/` or the README. + +## Security and reporting + +If you find a security issue: +- Do not open a public issue. +- Notify the org security contact or an admin. +- Include reproduction steps and impact. +- If PII may have been exposed, that is automatically a severity-1 incident. + +Security contact: +- Email: `security@workingorder.ai` +- Backup: `ops@workingorder.ai` + +## Support / ownership + +If you don't know who owns a repo: +- Check `CODEOWNERS` +- Check the repo "About" / topics +- Check `docs/` or the README header + +If it's still unclear, that's a repo hygiene bug — fix it. + +## Org layout + +``` +.github/ + profile/README.md ← org profile (this document or a summary) + CODEOWNERS + workflows/ ← shared CI patterns +docs/ + architecture/ ← system design, trust boundary diagrams + runbooks/ ← operational procedures + standards/ ← this document and related references +templates/ + repo-starter/ ← starter README, CONTRIBUTING, SECURITY templates + issue-templates/ + pr-templates/ +``` + +--- + +### Quick links + +- Engineering docs: `docs/` +- Runbooks / ops notes: `docs/runbooks/` +- Architecture diagrams: `docs/architecture/` +- New repo checklist: `docs/standards/new-repo-checklist.md` +- Trulana repo: [github.com/AdamsLocal/trulana](https://github.com/AdamsLocal/trulana) diff --git a/docs/demo.js b/docs/demo.js index 2cb7177..cee65cb 100644 --- a/docs/demo.js +++ b/docs/demo.js @@ -21,7 +21,7 @@ var piiPatterns = [ { rx: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g, tag: 'SSN REDACTED' }, { rx: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, tag: 'EMAIL REDACTED' }, - { rx: /(? 0 ? hits.join(' ') : q; + var raw = hits.length > 0 ? hits.join(' ') : 'No matching data found.'; var r = redact(raw); return { data: r.text, redactions: r.count, hits: hits.length }; } diff --git a/docs/index.html b/docs/index.html index 60a02e7..5784880 100644 --- a/docs/index.html +++ b/docs/index.html @@ -20,7 +20,9 @@ - + - + +

Redirecting to Trulana

+ diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d5748fd..91b1408 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -30,7 +30,7 @@ final appRouterProvider = Provider((Ref ref) { routes: [ GoRoute( path: '/', - redirect: (_, _) => '/dashboard', + redirect: (context, state) => '/dashboard', ), GoRoute( path: '/onboarding', @@ -60,7 +60,7 @@ final appRouterProvider = Provider((Ref ref) { /// refresh mechanism so redirects fire whenever auth state changes. class _RouterRefreshNotifier extends ChangeNotifier { _RouterRefreshNotifier(Ref ref, ProviderListenable provider) { - ref.listen(provider, (_, _) => notifyListeners()); + ref.listen(provider, (previous, next) => notifyListeners()); } } diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index ca6ac11..5affaa7 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -191,7 +191,7 @@ class _ContentHeader extends StatelessWidget { fontWeight: FontWeight.w600, color: TrulanaColors.textPrimary)), const Spacer(), - if (trailing != null) trailing!, + ?trailing, ], ), ); diff --git a/lib/features/shell/views/settings_view.dart b/lib/features/shell/views/settings_view.dart index f21aec6..18de30c 100644 --- a/lib/features/shell/views/settings_view.dart +++ b/lib/features/shell/views/settings_view.dart @@ -15,6 +15,7 @@ class SettingsView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final configAsync = ref.watch(userConfigProvider); + final settingsAsync = ref.watch(appSettingsProvider); return Column( children: [ @@ -55,7 +56,16 @@ class SettingsView extends ConsumerWidget { _SettingRow( label: 'Require Touch ID on launch', subtitle: 'Biometric gate every session', - trailing: const _ToggleSwitch(isOn: true), + trailing: settingsAsync.when( + loading: () => const _ToggleLoading(), + error: (_, __) => const _ToggleSwitch(isOn: true), + data: (settings) => _ToggleSwitch( + isOn: settings.requireBiometric, + onChanged: (value) => ref + .read(appSettingsProvider.notifier) + .setRequireBiometric(value), + ), + ), ), ], ), @@ -65,12 +75,30 @@ class SettingsView extends ConsumerWidget { _SettingRow( label: 'Auto-start REST server', subtitle: 'Binds to localhost:8432', - trailing: const _ToggleSwitch(isOn: true), + trailing: settingsAsync.when( + loading: () => const _ToggleLoading(), + error: (_, __) => const _ToggleSwitch(isOn: true), + data: (settings) => _ToggleSwitch( + isOn: settings.autoStartServer, + onChanged: (value) => ref + .read(appSettingsProvider.notifier) + .setAutoStartServer(value), + ), + ), ), _SettingRow( label: 'MCP stdio adapter', subtitle: 'For Claude Desktop / Cursor', - trailing: const _ToggleSwitch(isOn: true), + trailing: settingsAsync.when( + loading: () => const _ToggleLoading(), + error: (_, __) => const _ToggleSwitch(isOn: true), + data: (settings) => _ToggleSwitch( + isOn: settings.mcpAdapter, + onChanged: (value) => ref + .read(appSettingsProvider.notifier) + .setMcpAdapter(value), + ), + ), ), _SettingRow( label: 'Token TTL', @@ -272,10 +300,34 @@ class _SettingRow extends StatelessWidget { } } +/// Sized loading placeholder for while toggle state is being read. +class _ToggleLoading extends StatelessWidget { + const _ToggleLoading(); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 36, + height: 20, + child: Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + color: TrulanaColors.primaryCyan, + strokeWidth: 1.5, + ), + ), + ), + ); + } +} + class _ToggleSwitch extends StatefulWidget { - const _ToggleSwitch({required this.isOn}); + const _ToggleSwitch({required this.isOn, this.onChanged}); final bool isOn; + final ValueChanged? onChanged; @override State<_ToggleSwitch> createState() => _ToggleSwitchState(); @@ -290,10 +342,22 @@ class _ToggleSwitchState extends State<_ToggleSwitch> { _on = widget.isOn; } + @override + void didUpdateWidget(_ToggleSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isOn != widget.isOn) { + _on = widget.isOn; + } + } + @override Widget build(BuildContext context) { return GestureDetector( - onTap: () => setState(() => _on = !_on), + onTap: () { + final bool next = !_on; + setState(() => _on = next); + widget.onChanged?.call(next); + }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 36, diff --git a/lib/providers/app_settings_provider.dart b/lib/providers/app_settings_provider.dart new file mode 100644 index 0000000..1b9e884 --- /dev/null +++ b/lib/providers/app_settings_provider.dart @@ -0,0 +1,81 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:trulana/core/services/preferences_service.dart'; + +const String _kRequireBiometric = 'settings.require_biometric'; +const String _kAutoStartServer = 'settings.auto_start_server'; +const String _kMcpAdapter = 'settings.mcp_adapter'; + +/// Persisted boolean knobs for the Settings view: biometric gate, +/// server auto-start, MCP adapter. +/// +/// Values are stored as plain-text `'true'`/`'false'` preferences so that +/// [PreferencesService] remains the single source of truth for all app +/// settings. +class AppSettings { + const AppSettings({ + this.requireBiometric = true, + this.autoStartServer = true, + this.mcpAdapter = true, + }); + + final bool requireBiometric; + final bool autoStartServer; + final bool mcpAdapter; + + AppSettings copyWith({ + bool? requireBiometric, + bool? autoStartServer, + bool? mcpAdapter, + }) => + AppSettings( + requireBiometric: requireBiometric ?? this.requireBiometric, + autoStartServer: autoStartServer ?? this.autoStartServer, + mcpAdapter: mcpAdapter ?? this.mcpAdapter, + ); +} + +final appSettingsProvider = + AsyncNotifierProvider( + AppSettingsNotifier.new, +); + +class AppSettingsNotifier extends AsyncNotifier { + PreferencesService get _prefs => PreferencesService.instance; + + static bool _parseBool(String? raw, {required bool defaultValue}) { + if (raw == null) return defaultValue; + return switch (raw.trim().toLowerCase()) { + 'true' || '1' => true, + 'false' || '0' => false, + _ => defaultValue, + }; + } + + @override + Future build() async { + final biometric = await _prefs.getPreference(_kRequireBiometric); + final autoStart = await _prefs.getPreference(_kAutoStartServer); + final mcp = await _prefs.getPreference(_kMcpAdapter); + return AppSettings( + requireBiometric: _parseBool(biometric?.value, defaultValue: true), + autoStartServer: _parseBool(autoStart?.value, defaultValue: true), + mcpAdapter: _parseBool(mcp?.value, defaultValue: true), + ); + } + + Future setRequireBiometric(bool value) async { + await _prefs.setPreference(_kRequireBiometric, value.toString()); + state = AsyncData(state.requireValue.copyWith(requireBiometric: value)); + } + + Future setAutoStartServer(bool value) async { + await _prefs.setPreference(_kAutoStartServer, value.toString()); + state = AsyncData(state.requireValue.copyWith(autoStartServer: value)); + } + + Future setMcpAdapter(bool value) async { + await _prefs.setPreference(_kMcpAdapter, value.toString()); + state = AsyncData(state.requireValue.copyWith(mcpAdapter: value)); + } +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 4bf17d1..a11271e 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -2,3 +2,4 @@ export 'user_config_provider.dart'; export 'agents_provider.dart'; export 'audit_provider.dart'; export 'ledger_provider.dart'; +export 'app_settings_provider.dart';