Architecture
Primary–Secondary topology
Salon POS uses a Primary–Secondary architecture. Every device — including the Primary itself — talks to a single local HTTPS API. Only the Primary owns the SQLite database and syncs to the cloud.
All devices use the same HTTP repository classes, targeting the same Shelf API. There is no branching logic in client code — switching roles is a base-URL change.
Role state machine
Defined in lib/core/device/device_role_provider.dart.
Transitions:
- First launch defaults to
primary - Manual promotion/demotion via Settings
emergencyBufferauto-enters when the Primary is unreachable (3 failed pings → subnet scan for new Primary)emergencyBufferwithout a configured IP self-heals back toprimary
Bootstrap sequence
lib/main.dart (~650 lines) constructs the entire dependency graph imperatively before calling runApp:
- Firebase init — best-effort; failure is non-fatal
- DB seed —
seedAllDemoData()seeds demo data directly - Onboarding gate — reads
onboarding_completefromapp_settings - TLS cert — generate-on-first-run Ed25519 self-signed cert via
DeviceCertService - LAN auth —
LanAuthService: challenge/response + session tokens + roster verification - Observability —
ObsLoggerwith ring buffer + pending event queue + cloud/primary-forward sinks - Local HTTPS server —
LocalServer(Shelf): Primary serves LAN API; Secondaries forward through it - Cloud sync —
CloudSyncWorker+CloudSyncScheduler+BookingOutboxWorker - Push —
PushServicewith FCM nudge polling - Heartbeat —
HeartbeatScheduler: Primary telemetry - Roster —
RosterService: periodic cloud pull of device roster - Container —
ProviderContainerwith ~20 overrides for all singletons - Run —
runApp(UncontrolledProviderScope(container: container, child: SalonPosApp()))
LAN security model
TLS pinning (lib/core/network/tls_pinning.dart)
- SHA-256 fingerprint pinning is never disabled
- Missing fingerprints cause connection failure, not a downgrade
- Each device generates a self-signed Ed25519 cert on first run via
DeviceCertService
Device identity (lib/core/device/)
device_id_provider.dart— persistent UUID stored inSharedPreferencesdevice_keypair.dart— Ed25519 keypair stored influtter_secure_storage- Used for LAN authentication challenge-response signatures
LAN authentication (lib/core/network/lan_auth_service.dart)
- Ed25519 challenge-response protocol between devices
- Session tokens minted and validated per-request
lan_auth_middleware.dart— Shelf middleware forAuthorization: Bearer <token>lan_session_interceptor.dart— Dio interceptor attaching tokens to client requests
Device discovery
primary_locator.dart— subnet scan to find a Primary after handovercandidate_verifier.dart— verifies a discovered device is a legitimate Primaryroster_bootstrap.dart— bootstraps the device roster from LAN discovery
Shelf API server
lib/core/network/local_server.dart mounts 28+ domain handlers on a Shelf Router:
Each handler receives a Shelf.Request and returns a Shelf.Response. Handlers are fully testable by mounting them on a test Shelf server.
Dio HTTP client
lib/core/network/api_client.dart builds a TLS-pinned HTTPS Dio instance:
- Primary mode: targets
https://<local-ip>:8080 - Secondary mode: targets
https://<primary-ip>:8080 - EmergencyBuffer interceptor: catches connectivity failures, buffers writes to
SyncBuffer, flips role toemergencyBuffer kNoEmergencyBufferflag on Dio extras prevents background-timer triggers from buffering
EmergencyBuffer
lib/core/sync/sync_buffer_service.dart:
- When the Primary is unreachable,
EmergencyBufferInterceptorstores the request inSyncBuffertable - Every 30 seconds,
SyncBufferServicepings the Primary - On reconnect, replays all buffered operations in order
- After 3 consecutive failed pings, triggers a subnet scan for a new Primary
Offline-first design
- LAN traffic is the only critical path — cloud sync, telemetry, onboarding, and heartbeats all timeout, retry, and degrade gracefully
- Cloud sync is fire-and-forget —
CloudSyncWorkerdrains the outbox asynchronously - No blocking network calls on the UI thread — all repositories use async Dio calls
- Secondary devices have no database — they only buffer during emergency mode
UI shell
lib/app.dart:
SalonPosApp— wrapsScreenUtilInit→MaterialApp.routerwith EN/ZH localization, light/dark themesMainScaffold— persistent left NavigationRail with 8 destinations (Dashboard, Check-in, Tickets, Customers, Appointments, Reports, Card Transactions, Settings) plus RBAC-gated visibilityBrokenSecondaryScreen— full-screen fallback when role issecondarybut no Primary IP is configured
Router
lib/core/router/app_router.dart — ~35 GoRouter routes:
ShellRoutewrappingMainScaffold- Auth guard via
onboardingRedirect()— respects device role, onboarding state, staff session _SessionListenablebridges Riverpod watches to GoRouter'srefreshListenable