|
| 1 | +# Architecture |
| 2 | + |
| 3 | +## Domain Hierarchy |
| 4 | + |
| 5 | +Entities nest: **Account → Device → Module**. This maps directly to both the code packages and the on-disk storage layout. |
| 6 | + |
| 7 | +``` |
| 8 | +dataPath/accounts/{accountId}/ |
| 9 | +├── account.json |
| 10 | +├── shares/{shareId}.json |
| 11 | +└── devices/{deviceId}/ |
| 12 | + ├── device.json |
| 13 | + └── modules/{sha1(moduleId)}/ |
| 14 | + ├── blob (binary payload) |
| 15 | + └── meta.json (Module.Info) |
| 16 | +``` |
| 17 | + |
| 18 | +## Startup Flow |
| 19 | + |
| 20 | +``` |
| 21 | +App.main(args) → parse CLI config → DaggerAppComponent.build() → App.launch() → Server.start() |
| 22 | +``` |
| 23 | + |
| 24 | +`Server.start()` installs Ktor middleware (logging, WebSockets, content negotiation, body limits, rate limiting, status pages), then registers all routes. |
| 25 | + |
| 26 | +## Routing |
| 27 | + |
| 28 | +Each domain has a `*Route` class (`@Singleton`, injected by Dagger) with a `setup(Routing)` method called from `Server.kt`. All endpoints live under `/v1/`. |
| 29 | + |
| 30 | +| Route | Path | Purpose | |
| 31 | +|-------|------|---------| |
| 32 | +| AccountRoute | `/v1/account` | Create/delete accounts | |
| 33 | +| ShareRoute | `/v1/account/share` | Generate/consume share codes | |
| 34 | +| DeviceRoute | `/v1/devices` | List/delete/reset devices | |
| 35 | +| ModuleRoute | `/v1/module/{moduleId}` | Read/write/delete module data | |
| 36 | +| WsRoute | `/v1/ws` | WebSocket sync notifications | |
| 37 | +| StatusRoute | `/v1/status` | Health check | |
| 38 | +| MyIpRoute | `/v1/myip` | Client IP echo | |
| 39 | + |
| 40 | +## Persistence |
| 41 | + |
| 42 | +**No database.** All state is JSON files on disk + in-memory `ConcurrentHashMap` caches. |
| 43 | + |
| 44 | +- Repos (`AccountRepo`, `DeviceRepo`, `ModuleRepo`, `ShareRepo`) load everything into memory at startup via `runBlocking` in `init {}`. |
| 45 | +- File writes use `kotlinx.serialization`. |
| 46 | +- `Device` operations are protected by a per-device `Mutex`. |
| 47 | +- Module IDs are SHA-1 hashed for safe directory names. |
| 48 | + |
| 49 | +## Sync Flow |
| 50 | + |
| 51 | +1. Device A writes module → `POST /v1/module/{moduleId}` |
| 52 | +2. `ModuleRepo` stores data, calls `SyncNotifier.enqueue()` |
| 53 | +3. `SyncNotifier` debounces 500ms, then broadcasts `ModuleChanged` event |
| 54 | +4. `ConnectionRegistry` delivers event to all WebSocket sessions in the account except the originator |
| 55 | +5. Device B receives event, fetches updated data via `GET /v1/module/{moduleId}` |
| 56 | + |
| 57 | +## Authentication |
| 58 | + |
| 59 | +- `X-Device-ID` header: device UUID |
| 60 | +- `Authorization: Basic base64(accountId:devicePassword)` header |
| 61 | +- Device password: 50 random bytes, generated at registration, constant-time comparison |
| 62 | +- Helper: `HttpExtensions.authenticateDevice()` — parses headers, looks up device, verifies credentials, updates `lastSeen` |
| 63 | + |
| 64 | +## Background Jobs |
| 65 | + |
| 66 | +All run in `AppScope` (application-wide `SupervisorJob + Dispatchers.Default`): |
| 67 | + |
| 68 | +- **Account GC**: removes accounts with no devices after 10 min |
| 69 | +- **Device expiration**: removes devices not seen in 90 days |
| 70 | +- **Module expiration**: removes modules not accessed in 90 days |
| 71 | +- **Share expiration**: cleans up expired share codes (60 min TTL) |
| 72 | + |
| 73 | +## WebSocket Connection Limits |
| 74 | + |
| 75 | +Managed by `ConnectionRegistry`: |
| 76 | +- Per account: 64 |
| 77 | +- Per IP: 32 |
| 78 | +- Global: 10,000 |
| 79 | +- Frame rate: 120 frames/min per connection |
| 80 | +- Oldest session evicted when per-account limit exceeded |
0 commit comments